/* global React, ReactDOM */ // admin.jsx — Verwaltung dashboard for Konzentriert Mit Uns. // State is persisted to localStorage under STORAGE_KEY; the Onepager // reads the same key on load and listens to `storage` events so changes // here propagate live to any open public-site tab. const STORAGE_KEY = "konzentriert.termine"; const AUTH_KEY = "km.admin.authed"; const DEMO_PASSWORD = "juliadamaris"; // ─── Storage helpers ──────────────────────────────────────────────────── function normalize(source) { const list = Array.isArray(source.list) ? source.list : ["t1", "t2", "t3"].map((k) => source[k]).filter(Boolean); return { variant: source.variant === "b" ? "b" : "a", email: source.email || "", list: list.map((x, i) => ({ id: x.id || `k${Date.now()}_${i}`, title: x.title || "", dates: Array.isArray(x.dates) ? [...x.dates] : [], time: x.time || "", location: x.location || "", ageGroup: x.ageGroup || "", seats: x.seats || "", active: x.active !== false, })), }; } function loadData() { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) return normalize(JSON.parse(raw)); } catch (e) { /* fall through */ } return normalize(window.TERMINE_DEFAULTS || {}); } function saveData(data) { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } function resetData() { localStorage.removeItem(STORAGE_KEY); return normalize(window.TERMINE_DEFAULTS || {}); } // ─── Date helpers ─────────────────────────────────────────────────────── function fmtDateLong(iso) { const d = new Date(iso + "T00:00:00"); if (isNaN(d)) return iso; return d.toLocaleDateString("de-DE", { weekday: "short", day: "numeric", month: "short", year: "numeric" }); } function fmtDateShort(iso) { const d = new Date(iso + "T00:00:00"); if (isNaN(d)) return iso; return d.toLocaleDateString("de-DE", { day: "numeric", month: "long" }); } // ─── App root ─────────────────────────────────────────────────────────── function App() { const [authed, setAuthed] = React.useState( sessionStorage.getItem(AUTH_KEY) === "1" ); if (!authed) { return { sessionStorage.setItem(AUTH_KEY, "1"); setAuthed(true); }} />; } return { sessionStorage.removeItem(AUTH_KEY); setAuthed(false); }} />; } // ─── Login ────────────────────────────────────────────────────────────── function Login({ onAuth }) { const [pw, setPw] = React.useState(""); const [err, setErr] = React.useState(""); const submit = (e) => { e.preventDefault(); if (pw === DEMO_PASSWORD) { onAuth(); } else { setErr("Passwort stimmt nicht. Bitte erneut versuchen."); setPw(""); } }; return (

Verwaltung

Willkommen.

Bitte mit dem Verwaltungs­passwort anmelden, um Termine und Verfügbarkeit zu pflegen.

{ setPw(e.target.value); setErr(""); }} placeholder="Passwort" autoFocus /> {err &&

{err}

} ← Zurück zur Webseite
); } // ─── Dashboard ────────────────────────────────────────────────────────── function Dashboard({ onLogout }) { const [data, setData] = React.useState(loadData); const [baseline, setBaseline] = React.useState(() => JSON.stringify(loadData())); const [savedAt, setSavedAt] = React.useState(null); const [toast, setToast] = React.useState(""); const dirty = JSON.stringify(data) !== baseline; // Warn on tab close if dirty. React.useEffect(() => { const handler = (e) => { if (dirty) { e.preventDefault(); e.returnValue = ""; } }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [dirty]); const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(""), 2400); }; const save = () => { saveData(data); setBaseline(JSON.stringify(data)); setSavedAt(new Date()); showToast("Änderungen veröffentlicht."); }; const discard = () => { if (!confirm("Alle ungespeicherten Änderungen verwerfen?")) return; setData(JSON.parse(baseline)); }; const resetAll = () => { if (!confirm("Alle Termine auf die Ausgangsdaten zurücksetzen? Diese Aktion lässt sich nicht rückgängig machen.")) return; const fresh = resetData(); setData(fresh); setBaseline(JSON.stringify(fresh)); setSavedAt(new Date()); showToast("Auf Ausgangsdaten zurückgesetzt."); }; // Mutators const updateTermin = (i, patch) => setData((d) => ({ ...d, list: d.list.map((x, idx) => idx === i ? { ...x, ...patch } : x) })); const removeTermin = (i) => setData((d) => ({ ...d, list: d.list.filter((_, idx) => idx !== i) })); const addTermin = () => setData((d) => ({ ...d, list: [...d.list, { id: `k${Date.now()}`, title: "Neuer Kurs", dates: [], time: "09:00 – 11:30 Uhr", location: "Studio Düsseldorf", ageGroup: "8 – 14 Jahre", seats: "Anmeldung offen", active: true, }], })); const move = (i, dir) => setData((d) => { const list = [...d.list]; const j = i + dir; if (j < 0 || j >= list.length) return d; [list[i], list[j]] = [list[j], list[i]]; return { ...d, list }; }); // Stats const activeCourses = data.list.filter((t) => t.active).length; const totalDates = data.list.reduce((n, t) => n + (t.active ? t.dates.length : 0), 0); const upcoming = data.list .filter((t) => t.active) .flatMap((t) => t.dates) .filter((iso) => new Date(iso + "T00:00:00") >= new Date(new Date().toDateString())) .sort(); const nextIso = upcoming[0]; return ( <> {/* Top bar */}
Verwaltung
{savedAt && !dirty && ( Gespeichert · {savedAt.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} )} Webseite ansehen ↗
{/* Main */}
Hallo Julia & Damaris

Termine & Verfügbarkeit.

Pflegen Sie hier Daten, Uhrzeiten, Orte und freie Plätze. Mit »Änderungen veröffentlichen« erscheinen sie sofort auf der Webseite.

{/* Stats */}
{activeCourses} Aktive Kurse
{totalDates} Trainingstage geplant
{nextIso ? fmtDateShort(nextIso) : "—"} Nächster Termin
{/* Termine */}

Kurstermine

Pausierte Kurse erscheinen nicht auf der Webseite. Reihenfolge per ↑↓ ändern.

{data.list.map((termin, i) => ( updateTermin(i, patch)} onRemove={() => removeTermin(i)} onMoveUp={() => move(i, -1)} onMoveDown={() => move(i, 1)} /> ))} {data.list.length === 0 && (

Aktuell sind keine Termine angelegt.

)}
{/* Settings */}

Einstellungen

Gelten für alle Termine auf der Webseite.

setData({ ...data, email: e.target.value })} placeholder="hallo@konzentriert-mit-uns.de" />

Wird in den „Jetzt anfragen“-Buttons verwendet.

{/* Danger / reset */}

Erweitert

Auf Ausgangsdaten zurücksetzen

Setzt alle Termine und Einstellungen auf die im Code hinterlegten Werte zurück.

{/* Sticky save bar */} {dirty && (
Ungespeicherte Änderungen. Bereit zum Veröffentlichen?
)} {toast &&
♥ {toast}
} ); } // ─── Termin editor card ───────────────────────────────────────────────── function TerminEditor({ termin, first, last, onChange, onRemove, onMoveUp, onMoveDown }) { const [newDate, setNewDate] = React.useState(""); const addDate = () => { if (!newDate) return; const next = [...new Set([...termin.dates, newDate])].sort(); onChange({ dates: next }); setNewDate(""); }; const removeDate = (iso) => { onChange({ dates: termin.dates.filter((d) => d !== iso) }); }; const sortedDates = [...termin.dates].sort(); return (
onChange({ title: e.target.value })} placeholder="Kurstitel" />

Tipp: \n fügt einen Zeilenumbruch im Titel ein.

{sortedDates.length === 0 ?

Noch keine Daten hinterlegt — unten ein Datum auswählen.

:
    {sortedDates.map((iso) => (
  • {fmtDateLong(iso)}
  • ))}
}
setNewDate(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addDate(); } }} />
onChange({ time: e.target.value })} placeholder="z. B. 09:00 – 11:30 Uhr" />
onChange({ location: e.target.value })} placeholder="z. B. Studio Düsseldorf" />
onChange({ ageGroup: e.target.value })} placeholder="z. B. 8 – 11 Jahre" />
onChange({ seats: e.target.value })} placeholder="z. B. Noch 3 Plätze frei" />
{["Noch 1 Platz frei", "Noch 3 Plätze frei", "Wenige Plätze frei", "Anmeldung offen", "Warteliste"].map((opt) => ( ))}
); } // ─── Mount ────────────────────────────────────────────────────────────── // Wait for webfonts to load before mounting so the first paint isn't // immediately followed by a swap-reflow (which can trip Chromium's // "ResizeObserver loop" notice in observer-instrumented hosts). function mount() { ReactDOM.createRoot(document.getElementById("root")).render(); } if (document.fonts && document.fonts.ready) { document.fonts.ready.then(mount); } else { mount(); }