const { useState, useEffect, useCallback, useRef } = React;
const { createRoot } = ReactDOM;
const {
Bus, Clock, MapPin, LogOut, Plus, Check, Unlock,
ArrowLeft, ChevronUp, ChevronDown, Pencil, Trash2, Save, StickyNote,
Building2, Phone, RefreshCw, CalendarDays, List, Sun, Moon, Clapperboard,
} = Icons;
const API = "storage.php";
const ITEMS_POLL_MS = 6000; // "alle paar Sekunden" – Ablauf eines offenen Stops
const STOPS_POLL_MS = 15000; // Liste aller Stops (neue Termine etc.)
// ---------- API-Helfer ----------
function headers(withEdit) {
const h = { "Content-Type": "application/json", "X-View-Pin": window.pinStorage.getView() };
if (withEdit) h["X-Edit-Pin"] = window.pinStorage.getEdit();
return h;
}
async function apiGet(action, params = {}) {
const qs = new URLSearchParams({ action, ...params }).toString();
const r = await fetch(`${API}?${qs}`, { headers: headers(false) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || "Fehler beim Laden");
return r.json();
}
async function apiPost(body, withEdit = false) {
const r = await fetch(API, { method: "POST", headers: headers(withEdit), body: JSON.stringify(body) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || "Fehler beim Speichern");
return r.json();
}
async function login(pin) {
const r = await fetch(API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "login", pin }) });
return r.json();
}
// ---------- Datum-Helfer ----------
const WEEKDAYS = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
function parseDate(dateStr) {
const [y, m, d] = dateStr.split("-").map(Number);
return new Date(y, m - 1, d);
}
function dateBadge(dateStr) {
const d = parseDate(dateStr);
return { day: String(d.getDate()).padStart(2, "0"), month: MONTHS[d.getMonth()] };
}
function headerDate(dateStr) {
const d = parseDate(dateStr);
return `${WEEKDAYS[d.getDay()]}, ${String(d.getDate()).padStart(2, "0")}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
function todayISO() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
// Einträge ohne Startzeit werden ans Ende sortiert; bei gleicher Zeit bleibt
// die bisherige Reihenfolge erhalten (stabile Sortierung) – so wirkt sich
// ein manuelles Verschieben per Pfeil bei Zeitgleichheit weiterhin aus.
function sortByTime(items) {
return [...items].sort((a, b) => {
const at = a.startTime || "99:99";
const bt = b.startTime || "99:99";
return at.localeCompare(bt);
});
}
// ---------- URL-Deeplink (?date=YYYY-MM-DD) ----------
function getUrlDate() {
return new URLSearchParams(window.location.search).get("date") || "";
}
function setUrlDate(date) {
const url = new URL(window.location.href);
if (date) url.searchParams.set("date", date);
else url.searchParams.delete("date");
window.history.replaceState({}, "", url.toString());
}
// ---------- Theme ----------
function getStoredTheme() { return localStorage.getItem("hope-ablauf-theme") || "dark"; }
function setStoredTheme(t) { localStorage.setItem("hope-ablauf-theme", t); }
// ---------- PIN-Screen (einziger Ort, an dem der Bearbeitungsmodus vergeben wird) ----------
function PinScreen({ onSuccess }) {
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!pin) return;
setBusy(true);
setError("");
try {
const res = await login(pin);
if (res.view) {
window.pinStorage.setView(pin);
if (res.edit) window.pinStorage.setEdit(pin);
onSuccess(!!res.edit);
} else {
setError("Falscher PIN.");
}
} catch {
setError("Verbindung fehlgeschlagen.");
} finally {
setBusy(false);
}
};
return (
hope Ablauf
Tagesablauf der Tournee 2027 — bitte PIN eingeben
setPin(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
autoFocus
/>
{error &&
{error}
}
{busy ? "Prüfe…" : "Los geht's"}
Der Bearbeiten-PIN schaltet direkt beim Login den Bearbeitungsmodus frei — danach nur noch über Abmelden & neu einloggen wechselbar.
);
}
// ---------- Topbar ----------
function Topbar({ title, sub, onBack, isEditor, theme, onToggleTheme, onLogout }) {
return (
{onBack ? (
) : (
)}
{title}
{sub && {sub} }
{isEditor && Bearbeiten }
{theme === "dark" ? : }
);
}
// ---------- Stop-Liste ----------
function StopList({ stops, onSelect }) {
if (!stops.length) return Noch keine Tourstopps vorhanden.
;
return (
{stops.map((s) => {
const b = dateBadge(s.date);
const isToday = s.date === todayISO();
return (
onSelect(s.id)}>
{s.city}
{s.arena || (s.venue && s.venue.name) || ""}
);
})}
);
}
// ---------- Schedule-Item-Sheet ----------
function ItemSheet({ initial, onClose, onSave }) {
const [title, setTitle] = useState(initial?.title || "");
const [startTime, setStartTime] = useState(initial?.startTime || "");
const [endTime, setEndTime] = useState(initial?.endTime || "");
const [iconType, setIconType] = useState(initial?.iconType || "clock");
const [note, setNote] = useState(initial?.note || "");
const [tags, setTags] = useState(initial?.tags || "");
const save = () => {
if (!title.trim()) return;
onSave({ ...initial, title: title.trim(), startTime, endTime, iconType, note, tags });
onClose();
};
return (
e.stopPropagation()}>
{initial?.id ? "Eintrag bearbeiten" : "Neuer Eintrag"}
Titel
setTitle(e.target.value)} autoFocus />
Art
setIconType("clock")}>
Termin
setIconType("bus")}>
Bus / Shuttle
Notiz (optional)
Namen / Gruppe (optional)
setTags(e.target.value)} />
Abbrechen
Speichern
);
}
// ---------- Notiz-Sheet ----------
function NotesSheet({ initial, onClose, onSave }) {
const [text, setText] = useState(initial || "");
return (
e.stopPropagation()}>
Day Notes
Abbrechen
{ onSave(text); onClose(); }}> Speichern
);
}
// ---------- Venue/Hotel-Sheet ----------
function PlaceSheet({ kind, initial, onClose, onSave }) {
const [name, setName] = useState(initial?.name || "");
const [street, setStreet] = useState(initial?.street || "");
const [zip, setZip] = useState(initial?.zip || "");
const [city, setCity] = useState(initial?.city || "");
const [phone, setPhone] = useState(initial?.phone || "");
const [web, setWeb] = useState(initial?.web || "");
const save = () => {
const data = { name, street, zip, city, phone };
if (kind === "venue") data.web = web;
onSave(data);
onClose();
};
return (
);
}
// ---------- Stop-Detail (Inhalt, ohne eigene Topbar) ----------
function StopDetail({ stop, items: rawItems, isEditor, onSaveItems, onSaveStop }) {
const [sheet, setSheet] = useState(null);
const items = sortByTime(rawItems);
const move = (idx, dir) => {
const next = [...items];
const j = idx + dir;
if (j < 0 || j >= next.length) return;
[next[idx], next[j]] = [next[j], next[idx]];
onSaveItems(next);
};
const removeItem = (idx) => {
if (!confirm("Diesen Eintrag wirklich löschen?")) return;
onSaveItems(items.filter((_, i) => i !== idx));
};
const saveItem = (item) => {
let next;
if (item.id) next = items.map((it) => (it.id === item.id ? item : it));
else next = [...items, item];
onSaveItems(sortByTime(next));
};
const formatTime = (it) => (!it.startTime ? "" : it.endTime ? `${it.startTime} – ${it.endTime}` : it.startTime);
return (
<>
{(stop.venue && stop.venue.name) || stop.arena || stop.city}
{headerDate(stop.date)}
Day Notes
{isEditor &&
setSheet({ type: "notes" })}> }
{stop.dayNotes || (isEditor ? "Noch keine Notiz — antippen zum Hinzufügen." : "Keine Notizen für diesen Tag.")}
Ablauf
{isEditor && (
setSheet({ type: "item" })}>
)}
{items.length === 0 &&
Noch keine Einträge.
}
{items.map((it, idx) => (
{it.iconType === "bus" ? : }
{formatTime(it) &&
{formatTime(it)}
}
{it.title}
{it.note &&
{it.note}
}
{it.tags &&
{it.tags}
}
{isEditor && (
setSheet({ type: "item", data: it })}>
removeItem(idx)}>
)}
{isEditor && (
move(idx, -1)} disabled={idx === 0}>
move(idx, 1)} disabled={idx === items.length - 1}>
)}
))}
Venue
{isEditor &&
setSheet({ type: "venue" })}> }
{stop.venue && stop.venue.name ? (
<>
{stop.venue.name}
{stop.venue.street}, {stop.venue.zip} {stop.venue.city}
{stop.venue.phone &&
}
{stop.venue.web &&
}
>
) :
Keine Venue-Daten hinterlegt.
}
Hotel
{isEditor &&
setSheet({ type: "hotel" })}> }
{stop.hotel && stop.hotel.name ? (
<>
{stop.hotel.name}
{stop.hotel.street}, {stop.hotel.zip} {stop.hotel.city}
{stop.hotel.phone &&
}
>
) :
Keine Hotel-Daten hinterlegt.
}
{sheet?.type === "item" && setSheet(null)} onSave={saveItem} />}
{sheet?.type === "notes" && setSheet(null)} onSave={(text) => onSaveStop({ dayNotes: text })} />}
{sheet?.type === "venue" && setSheet(null)} onSave={(venue) => onSaveStop({ venue })} />}
{sheet?.type === "hotel" && setSheet(null)} onSave={(hotel) => onSaveStop({ hotel })} />}
>
);
}
// ---------- Regieplan (statisch, keine Datenbank – ändert sich nicht) ----------
const SHOW_PROGRAM = {
title: "Programmablauf VIVA",
halves: [
{
label: "1. Hälfte",
items: [
{ text: "Trailer" },
{ text: "President" },
{ divider: true },
{ text: "Intro Movie & Live Cam Intro", artist: "Rosemie" },
{ text: "Opening", artist: "All Artists" },
{ text: "Local Show Group" },
{ text: "Slack Wire", artist: "Alexey Glavatskyi" },
{ text: "Bon Bon Step", artist: "Rosemie" },
{ text: "BMX", artist: "Tim Höfel" },
{ text: "Beams", artist: "Showteam & Turnerinnen" },
{ text: "Song 1", artist: "Rosemie" },
{ text: "Swinging Trapez", artist: "Avital & Jochen" },
{ text: "Hand to Hand", artist: "Duo in Motion" },
{ text: "Parallel Bars", artist: "Lukas Dauser" },
{ text: "Umarmen", artist: "Rosemie" },
{ text: "Parcours Pole", artist: "Chinese National Acrobatic Troupe" },
{ text: "Outro First Half" },
],
},
{
label: "2. Hälfte",
items: [
{ text: "Intro Second Half" },
{ text: "X-Boards", artist: "Motus" },
{ text: "Rap", artist: "Rosemie" },
{ text: "Quickchange", artist: "Duo in Motion" },
{ text: "Pommel Horse & Breakdance", artist: "Andy Toba, Michi & Showteam" },
{ text: "Aerial Pole", artist: "Duo Soulmates" },
{ text: "Jump Rope", artist: "Haribow" },
{ text: "Cyr Wheel", artist: "Alexey Glavatskyi" },
{ text: "Alphorn", artist: "Rosemie" },
{ text: "Rhythmic Gymnastics", artist: "Anastasiia Tsibulska" },
{ text: "Aerial Rope", artist: "Two on the Rope" },
{ text: "High Bar", artist: "Lukas Dauser, Andy Toba & Showteam" },
{ text: "Song 1", artist: "Rosemie" },
{ text: "Bicycles", artist: "Chinese National Acrobatic Troupe" },
{ text: "Grand Finale", artist: "All Artists" },
{ text: "Encore", artist: "All Artists" },
{ text: "Trailer 2027 & After Movie" },
],
},
],
};
function RegieplanView() {
let counter = 0;
return (
<>
{SHOW_PROGRAM.title}
Regieplan / Ablaufplan der Show
{SHOW_PROGRAM.halves.map((half) => (
{half.label}
{half.items.map((it, idx) =>
it.divider ? (
• • •
) : (
{String(++counter).padStart(2, "0")}
{it.text}
{it.artist &&
{it.artist}
}
)
)}
))}
Dieser Ablauf ist fest hinterlegt und ändert sich nicht automatisch mit den Tourstopps.
>
);
}
// ---------- Bottom-Nav ----------
function BottomNav({ tab, onChange }) {
return (
onChange("today")}>
Tagesübersicht
onChange("tourplan")}>
Tourplan
onChange("regieplan")}>
Regieplan
);
}
// ---------- App ----------
function App() {
const [loggedIn, setLoggedIn] = useState(!!window.pinStorage.getView());
const [isEditor, setIsEditor] = useState(!!window.pinStorage.getEdit());
const [theme, setTheme] = useState(getStoredTheme());
const [tab, setTab] = useState("today");
const [stops, setStops] = useState([]);
const [tourplanStopId, setTourplanStopId] = useState(null);
const [itemsByStop, setItemsByStop] = useState({});
const [loading, setLoading] = useState(false);
const loadStops = useCallback(async () => {
try {
const data = await apiGet("stops");
setStops(data);
} catch (e) {
if (String(e.message).includes("PIN")) { window.pinStorage.clearAll(); setLoggedIn(false); }
}
}, []);
const loadItems = useCallback(async (stopId) => {
if (!stopId) return;
try {
const data = await apiGet("schedule", { stopId });
setItemsByStop((prev) => ({ ...prev, [stopId]: data }));
} catch { /* stiller Fehlschlag bei Zwischen-Polls */ }
}, []);
// "overrideDate" ersetzt für die Tagesübersicht das echte Kalenderdatum,
// wenn ein Deeplink (?date=...) verwendet wurde.
const [overrideDate, setOverrideDate] = useState(() => getUrlDate() || null);
const effectiveTodayDate = overrideDate || todayISO();
const todayStop = stops.find((s) => s.date === effectiveTodayDate);
const tourplanStop = stops.find((s) => s.id === tourplanStopId);
const displayedStop = tab === "today" ? todayStop : tourplanStop;
// Initiales Laden nach Login
useEffect(() => {
if (!loggedIn) return;
setLoading(true);
loadStops().finally(() => setLoading(false));
}, [loggedIn, loadStops]);
// Deeplink beim ersten Laden sicherstellen: Tagesübersicht-Tab aktivieren
useEffect(() => {
if (overrideDate) setTab("today");
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Stops regelmäßig aktualisieren (neue Tourstopps, geänderte Adressen etc.)
useEffect(() => {
if (!loggedIn) return;
const t = setInterval(loadStops, STOPS_POLL_MS);
return () => clearInterval(t);
}, [loggedIn, loadStops]);
// Ablauf des aktuell angezeigten Stops regelmäßig aktualisieren
useEffect(() => {
if (!displayedStop) return;
loadItems(displayedStop.id);
const t = setInterval(() => loadItems(displayedStop.id), ITEMS_POLL_MS);
return () => clearInterval(t);
}, [displayedStop?.id, loadItems]);
// Beim Zurückkehren in den Tab sofort aktualisieren
useEffect(() => {
const onVis = () => {
if (document.visibilityState === "visible") {
loadStops();
if (displayedStop) loadItems(displayedStop.id);
}
};
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, [loadStops, loadItems, displayedStop]);
// Theme anwenden
useEffect(() => { setStoredTheme(theme); }, [theme]);
const items = displayedStop ? (itemsByStop[displayedStop.id] || []) : [];
const saveItems = async (next) => {
if (!displayedStop) return;
setItemsByStop((prev) => ({ ...prev, [displayedStop.id]: next }));
await apiPost({ action: "save_schedule", stopId: displayedStop.id, items: next }, true);
loadItems(displayedStop.id);
};
const saveStopFields = async (patch) => {
if (!displayedStop) return;
const merged = { ...displayedStop, ...patch };
setStops((prev) => prev.map((s) => (s.id === displayedStop.id ? merged : s)));
await apiPost({ action: "save_stop", stopId: displayedStop.id, dayNotes: merged.dayNotes, venue: merged.venue, hotel: merged.hotel }, true);
loadStops();
};
const openTourplanStop = (id) => setTourplanStopId(id);
const closeTourplanStop = () => setTourplanStopId(null);
const resetToRealToday = () => {
setOverrideDate(null);
setUrlDate("");
};
const logout = () => {
window.pinStorage.clearAll();
setLoggedIn(false); setIsEditor(false); setTourplanStopId(null); setTab("today");
setOverrideDate(null);
setUrlDate("");
};
if (!loggedIn) {
return (
{ setLoggedIn(true); setIsEditor(edit); }} />
);
}
let title, sub, onBack;
if (tab === "today") {
title = "Tagesübersicht";
if (todayStop) {
sub = overrideDate ? `${todayStop.city} · verlinktes Datum` : todayStop.city;
} else {
sub = overrideDate ? "Kein Tourstopp an diesem Datum" : "Heute kein Tourstopp";
}
} else if (tab === "regieplan") {
title = "Regieplan";
sub = "Programmablauf";
} else if (tourplanStop) {
title = tourplanStop.city;
sub = null;
onBack = closeTourplanStop;
} else {
title = "Tourplan";
sub = "Alle Stopps";
}
return (
setTheme((t) => (t === "dark" ? "light" : "dark"))}
onLogout={logout}
/>
{tab === "today" && (
<>
{overrideDate && (
Zum echten heutigen Datum zurück
)}
{todayStop ? (
) : (
{overrideDate ? "Für dieses Datum ist kein Tourstopp hinterlegt." : "Für heute ist kein Tourstopp hinterlegt."}
Unter „Tourplan" siehst du alle kommenden Termine.
)}
>
)}
{tab === "tourplan" && (
tourplanStop ? (
) : loading ? (
Lade Tourstopps…
) : (
)
)}
{tab === "regieplan" && }
{ setTab(t); closeTourplanStop(); }} />
);
}
const __root = createRoot(document.getElementById("root"));
__root.render( );