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:
PaddyandClaude Opus 5.5 committed 2026-09-28 22:15:59 +02:00
1 parent f82255961b
commit 0cda40f85e
138 files changed
+15942 -24010

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
"use client";
import { LogOutIcon, RefreshCwIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { AdminSessionProvider } from "@/components/admin/AdminSession";
import { SubscriptionsDialog } from "@/components/admin/SubscriptionsDialog";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Toaster } from "@/components/ui/sonner";
import { goToAdminLogin, signOut } from "@/lib/admin-auth";
import { api, ApiError } from "@/lib/api";
import type { AdminUser } from "@/lib/types";
type Failure = "denied" | "unavailable";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// /admin/login sits under this layout too, and must not be gated: it is the
// forwarder signed-out visitors are sent to. Gating it would hand the admin
// app the forwarder's own URL as the return target - so signing in would
// land back on the forwarder, which forwards again.
const isLoginPage = pathname === "/admin/login";
const [me, setMe] = useState<AdminUser | null>(null);
const [failure, setFailure] = useState<Failure | null>(null);
// The one call that decides whether the admin area renders. proxy.ts already
// bounced visitors with no cookie at all, but only the API can tell a valid
// session from a stale one, or a colleague without the calendar permission
// from one who has it.
//
// Exactly one status sends the user to the login page: 401. Anything else
// means they may well be signed in already, and bouncing them would sign them
// in, return them here and fail again - an endless loop with nothing on
// screen to explain it, triggered by exactly the partial outage most likely
// just after a deploy. 403 is the permission case; everything else (500, the
// API's 503 when the admin database is down, a network or CORS failure as
// status 0) is a fault worth showing.
useEffect(() => {
if (isLoginPage) return;
api
.me()
.then((user) => {
// Signed in, but not for this app. The API would refuse every write,
// so say so once instead of failing per action.
if (!user.apps.includes("calendar")) {
setFailure("denied");
return;
}
setMe(user);
})
.catch((e: unknown) => {
const status = e instanceof ApiError ? e.status : 0;
if (status === 401) {
goToAdminLogin();
return;
}
setFailure(status === 403 ? "denied" : "unavailable");
});
}, [isLoginPage]);
if (isLoginPage) {
return <>{children}</>;
}
if (failure) {
return <FailureScreen failure={failure} />;
}
return (
<div className="flex min-h-dvh flex-col">
<header className="sticky top-0 z-30 border-b bg-background/90 pt-[env(safe-area-inset-top)] backdrop-blur supports-[backdrop-filter]:bg-background/75">
<div className="mx-auto flex h-14 w-full max-w-4xl items-center justify-between gap-3 px-4 sm:px-6">
<Link href="/admin" className="flex items-baseline gap-2">
<span className="font-semibold tracking-tight">Nachklang</span>
<span className="text-sm text-muted-foreground">Kalender</span>
</Link>
<div className="flex items-center gap-1 sm:gap-3">
{me ? (
<span className="hidden max-w-48 truncate text-sm text-muted-foreground sm:inline">{me.fullName}</span>
) : (
<Skeleton className="hidden h-4 w-24 sm:block" />
)}
{me && <SubscriptionsDialog />}
<Button variant="ghost" size="sm" onClick={() => void signOut()} aria-label="Abmelden">
<LogOutIcon />
<span className="hidden sm:inline">Abmelden</span>
</Button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-4xl flex-1 px-4 pt-4 pb-[calc(6rem+env(safe-area-inset-bottom))] sm:px-6 sm:pt-6">
{me ? <AdminSessionProvider value={me}>{children}</AdminSessionProvider> : <LoadingShell />}
</main>
<Toaster position="top-center" />
</div>
);
}
/** What the page looks like while /me is in flight - the list's shape, not a blank page. */
function LoadingShell() {
return (
<div className="flex flex-col gap-3" aria-busy="true" aria-label="Wird geladen">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-5 w-32" />
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
);
}
function FailureScreen({ failure }: { failure: Failure }) {
const denied = failure === "denied";
return (
<div className="mx-auto flex min-h-dvh max-w-md flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-lg font-semibold">{denied ? "Kein Zugriff auf den Kalender" : "Kalender nicht erreichbar"}</h1>
<p className="text-sm text-muted-foreground">
{denied
? "Dein Konto ist angemeldet, hat aber keine Berechtigung für den Kalender. Eine Administratorin oder ein Administrator kann sie dir in der Verwaltung geben – danach die Seite neu laden."
: "Die Verwaltung ist gerade nicht erreichbar. Bitte lade die Seite in einem Moment neu."}
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" onClick={() => window.location.reload()}>
<RefreshCwIcon />
Neu laden
</Button>
{denied && (
<Button variant="ghost" onClick={() => void signOut()}>
<LogOutIcon />
Abmelden
</Button>
)}
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect, useState } from "react";
import { goToAdminLogin } from "@/lib/admin-auth";
/**
* This app has no login form: signing in happens once, on admin.nachklang.art,
* for every Nachklang app. Copied from nachklang-feedback.
*
* The route exists as a forwarder so proxy.ts has a stable path on this origin
* to send signed-out visitors to, rather than building an external URL inside
* the proxy.
*/
export default function AdminLoginPage() {
// Read from the URL directly rather than with useSearchParams(): this page is
// prerendered, and useSearchParams() opts the whole route into client-side
// rendering unless it sits inside a <Suspense> boundary.
const [redirect] = useState(() =>
typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("redirect"),
);
useEffect(() => {
// Passed straight through; the admin app validates it against its own
// origin allow-list, and a second check here could only drift from that.
goToAdminLogin(redirect || "/admin");
}, [redirect]);
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<p className="text-sm text-muted-foreground">Weiterleitung zur Nachklang-Anmeldung …</p>
</div>
);
}
+221
View File
@@ -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>
);
}