Rewrite the calendar frontend in Next.js
Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn, the same stack and design system as nachklang-feedback, planned in docs/rewrite-plan.md. - Admin: all calendars in one mobile-friendly list (month groups, calendar chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit sheet for create/edit/duplicate/delete, publish confirmation, and an "Abonnieren" dialog with subscription links for every calendar. - Public landing page: subscribe links from env instead of hardcoded, plus the next public events, server-rendered and revalidated every 5 minutes. - All times handled explicitly as Europe/Berlin; whole-day events stored as 00:00-23:59. Fixes carried over from the Angular app: the move dialog leaking a cancelled calendar change, saves that looked saved when they failed, in-place Date mutation, and duplicated calendar-id mappings. - Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403). - 112 Vitest tests over lib/; docs in docs/ (architecture, features, gotchas, API wish list, deployment). Needs the API branch feature/calendar-ical-escaping-subscriptions deployed first (GET /calendar/events/subscriptions). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
138 files changed
+15942
-24010
No files matched your search
@@ -0,0 +1,221 @@
|
||||
"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 (
|
||||
<Suspense>
|
||||
<EventsPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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<number | null>(null);
|
||||
const [confirmPublish, setConfirmPublish] = useState<CalendarEvent | null>(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<number, number> = {};
|
||||
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<string | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Termine</h1>
|
||||
{!selectedLoading && (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{visible.length === 1 ? "1 Termin" : `${visible.length} Termine`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={newEvent} className="hidden sm:inline-flex">
|
||||
<PlusIcon />
|
||||
Neuer Termin
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<EventsToolbar
|
||||
query={state.query}
|
||||
range={state.range}
|
||||
sort={state.sort}
|
||||
loading={selectedLoading}
|
||||
onQueryChange={(query) => update({ query })}
|
||||
onRangeChange={(range) => update(withRange(state, range))}
|
||||
onSortChange={(sort) => update({ sort })}
|
||||
onReload={() => reload()}
|
||||
/>
|
||||
|
||||
<CalendarChips selected={state.calendarIds} counts={counts} loads={loads} onToggle={toggleCalendar} />
|
||||
|
||||
{failed.length > 0 && (
|
||||
<div role="alert" className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm sm:flex-row sm:items-center">
|
||||
<div className="flex flex-1 gap-2">
|
||||
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<p>
|
||||
{failed.map((c) => c.label).join(", ")} {failed.length === 1 ? "konnte" : "konnten"} nicht geladen werden:{" "}
|
||||
{(loads[failed[0].id] as { message: string }).message}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => failed.forEach((c) => reload(c.id))}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EventList
|
||||
events={visible}
|
||||
now={now}
|
||||
sort={state.sort}
|
||||
range={state.range}
|
||||
query={state.query}
|
||||
loading={selectedLoading}
|
||||
noCalendars={state.calendarIds.length === 0}
|
||||
publishingId={publishingId}
|
||||
todayMarker={todayMarker}
|
||||
onOpen={(event) => openSheet({ kind: "edit", event })}
|
||||
onPublish={setConfirmPublish}
|
||||
onClearQuery={() => update({ query: "" })}
|
||||
/>
|
||||
|
||||
{/* Phones: a thumb-reach button instead of the header one. */}
|
||||
<Button
|
||||
onClick={newEvent}
|
||||
size="icon-lg"
|
||||
aria-label="Neuer Termin"
|
||||
className="fixed right-5 bottom-[calc(1.25rem+env(safe-area-inset-bottom))] z-20 size-14 rounded-full shadow-lg sm:hidden [&_svg:not([class*='size-'])]:size-6"
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
|
||||
<EventSheet
|
||||
key={sheet.key}
|
||||
open={sheet.open}
|
||||
mode={sheet.mode}
|
||||
now={now}
|
||||
onClose={() => 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 })}
|
||||
/>
|
||||
|
||||
<PublishDialog
|
||||
event={confirmPublish}
|
||||
now={now}
|
||||
publishing={publishingId !== null}
|
||||
onConfirm={publish}
|
||||
onCancel={() => setConfirmPublish(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user