"use client"; import { CircleAlertIcon, PlusIcon } from "lucide-react"; import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { CalendarChips } from "@/components/admin/CalendarChips"; import { EventList } from "@/components/admin/EventList"; import { EventSheet, type SheetMode } from "@/components/admin/EventSheet"; import { EventsToolbar } from "@/components/admin/EventsToolbar"; import { PublishDialog } from "@/components/admin/PublishDialog"; import { useEvents } from "@/components/admin/useEvents"; import { useListState } from "@/components/admin/useListState"; import { Button } from "@/components/ui/button"; import { goToAdminLogin } from "@/lib/admin-auth"; import { api } from "@/lib/api"; import { describeApiError, isSessionExpired } from "@/lib/api-errors"; import { CALENDARS } from "@/lib/calendars"; import { todayMarkerIndex } from "@/lib/event-display"; import { payloadFromEvent } from "@/lib/event-form"; import { filterEvents, matchesQuery, matchesRange, sortEvents } from "@/lib/filter"; import type { CalendarEvent } from "@/lib/types"; import { withRange } from "@/lib/url-state"; export default function AdminPage() { // useSearchParams() (in useListState) needs a Suspense boundary in a prerendered page. return ( ); } function EventsPage() { const [state, update] = useListState(); const { events, loads, reload, refresh, upsertEvent, removeEvent } = useEvents(); // Fixed per visit: "upcoming" should not reshuffle while someone is looking at it. const [now] = useState(() => new Date()); const [publishingId, setPublishingId] = useState(null); const [confirmPublish, setConfirmPublish] = useState(null); // The sheet: `mode` outlives `open` by the closing animation; `sheetKey` // remounts it per opening so no state carries over between events. const [sheet, setSheet] = useState<{ open: boolean; mode: SheetMode | null; key: number }>({ open: false, mode: null, key: 0 }); const openSheet = (mode: SheetMode) => setSheet((s) => ({ open: true, mode, key: s.key + 1 })); const visible = useMemo( () => sortEvents( filterEvents(events, { calendarIds: state.calendarIds, range: state.range, query: state.query }, now), state.sort, now, ), [events, state, now], ); // Per-chip counts under the current range and search, whether the chip is on or not. const counts = useMemo(() => { const result: Record = {}; for (const e of events) { if (matchesRange(e, state.range, now) && matchesQuery(e, state.query)) { result[e.calendarId] = (result[e.calendarId] ?? 0) + 1; } } return result; }, [events, state.range, state.query, now]); const selectedLoading = state.calendarIds.some((id) => loads[id]?.status === "loading"); // "Alle" spans years; the "Heute" marker shows where now is, and opening the // range scrolls to it. Again after a change of calendars or sort, but not on // every keystroke in the search, which would make the page jump while typing. const todayMarker = state.range === "all" ? todayMarkerIndex(visible, state.sort, now) : null; const scrolledFor = useRef(null); useEffect(() => { if (state.range !== "all") { scrolledFor.current = null; return; } if (selectedLoading || todayMarker === null) return; const key = `${state.sort}|${state.calendarIds.join(",")}`; if (scrolledFor.current === key) return; scrolledFor.current = key; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; document.getElementById("heute")?.scrollIntoView({ block: "center", behavior: reduceMotion ? "auto" : "smooth" }); }, [state.range, state.sort, state.calendarIds, selectedLoading, todayMarker]); const failed = CALENDARS.filter((c) => state.calendarIds.includes(c.id) && loads[c.id]?.status === "error"); // A new event goes into the calendar being looked at - when that is unambiguous. const newEvent = () => openSheet({ kind: "new", calendarId: state.calendarIds.length === 1 ? state.calendarIds[0] : 0 }); const onSaved = (saved: CalendarEvent, previous: CalendarEvent | null) => { upsertEvent(saved); // Then fetch the server's view: uuid, timestamps, resolved author names. refresh(saved.calendarId); if (previous && previous.calendarId !== saved.calendarId) refresh(previous.calendarId); const shown = filterEvents([saved], { calendarIds: state.calendarIds, range: state.range, query: state.query }, now); if (shown.length === 0) { toast.info("Der Termin ist mit den aktuellen Filtern ausgeblendet."); } }; const toggleCalendar = (calendarId: number) => update({ calendarIds: state.calendarIds.includes(calendarId) ? state.calendarIds.filter((id) => id !== calendarId) : [...state.calendarIds, calendarId], }); // Resends the whole event with only the status changed: PUT replaces every // field, so a partial body would drop the recurrence and anything else left out. const publish = async (event: CalendarEvent) => { setPublishingId(event.eventId); try { await api.updateEvent(event.eventId, payloadFromEvent(event, "PUBLIC")); upsertEvent({ ...event, status: "PUBLIC" }); setConfirmPublish(null); toast.success(`„${event.name}“ ist veröffentlicht.`); refresh(event.calendarId); } catch (e) { if (isSessionExpired(e)) { toast.error("Deine Sitzung ist abgelaufen. Du wirst zur Anmeldung weitergeleitet …"); goToAdminLogin(); return; } toast.error("Veröffentlichen fehlgeschlagen", { description: describeApiError(e) }); } finally { setPublishingId(null); } }; return ( Termine {!selectedLoading && ( {visible.length === 1 ? "1 Termin" : `${visible.length} Termine`} )} Neuer Termin update({ query })} onRangeChange={(range) => update(withRange(state, range))} onSortChange={(sort) => update({ sort })} onReload={() => reload()} /> {failed.length > 0 && ( {failed.map((c) => c.label).join(", ")} {failed.length === 1 ? "konnte" : "konnten"} nicht geladen werden:{" "} {(loads[failed[0].id] as { message: string }).message} failed.forEach((c) => reload(c.id))}> Erneut versuchen )} openSheet({ kind: "edit", event })} onPublish={setConfirmPublish} onClearQuery={() => update({ query: "" })} /> {/* Phones: a thumb-reach button instead of the header one. */} setSheet((s) => ({ ...s, open: false }))} onClosed={() => setSheet((s) => (s.open ? s : { ...s, mode: null }))} onSaved={onSaved} onDeleted={(event) => removeEvent(event.eventId)} onDuplicate={(event) => openSheet({ kind: "duplicate", event })} /> setConfirmPublish(null)} /> ); }
{failed.map((c) => c.label).join(", ")} {failed.length === 1 ? "konnte" : "konnten"} nicht geladen werden:{" "} {(loads[failed[0].id] as { message: string }).message}