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

+151
View File
@@ -0,0 +1,151 @@
"use client";
import { CalendarPlusIcon, CheckIcon, CircleAlertIcon, CopyIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { goToAdminLogin } from "@/lib/admin-auth";
import { api } from "@/lib/api";
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
import { CALENDARS, type CalendarSlug } from "@/lib/calendars";
import { toGoogleCalendar, toWebcal } from "@/lib/public-calendar";
import { cn } from "@/lib/utils";
type Subscription = { calendar: CalendarSlug; icalUrl: string };
type Load = { status: "idle" } | { status: "loading" } | { status: "ready"; subscriptions: Subscription[] } | { status: "error"; message: string };
/**
* Subscription links for every calendar, for the editor's own phone or to
* pass on to members. The URLs of the internal calendars carry their shared
* password, which is why they come from the API (GET
* /calendar/events/subscriptions, editors only) rather than being built here -
* the browser has no other way to know the passwords.
*
* Fetched each time the dialog opens, so a rotated password shows up at once.
*/
export function SubscriptionsDialog() {
const [load, setLoad] = useState<Load>({ status: "idle" });
const [copied, setCopied] = useState<CalendarSlug | null>(null);
const fetchLinks = async () => {
setLoad({ status: "loading" });
try {
setLoad({ status: "ready", subscriptions: await api.getSubscriptions() });
} catch (e) {
if (isSessionExpired(e)) {
goToAdminLogin();
return;
}
setLoad({ status: "error", message: describeApiError(e) });
}
};
const copy = async (subscription: Subscription) => {
try {
await navigator.clipboard.writeText(subscription.icalUrl);
setCopied(subscription.calendar);
setTimeout(() => setCopied((current) => (current === subscription.calendar ? null : current)), 2000);
} catch {
// No clipboard access: the URL is selectable in the dialog instead.
setCopied(null);
}
};
return (
<Dialog
onOpenChange={(open) => {
if (open) void fetchLinks();
}}
>
<DialogTrigger render={<Button variant="ghost" size="sm" aria-label="Abonnieren" title="Kalender abonnieren" />}>
<CalendarPlusIcon />
<span className="hidden sm:inline">Abonnieren</span>
</DialogTrigger>
{/* minmax(0,1fr): an implicit grid column grows to its longest unbreakable content - here a
full URL - so `truncate` on the URL never kicked in and the dialog's content overflowed. */}
<DialogContent className="max-h-[90dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Kalender abonnieren</DialogTitle>
<DialogDescription>
Mit diesen Links erscheinen die Termine automatisch in einer Kalender-App. Die Links der internen Kalender
enthalten das jeweilige Kalender-Passwort – bitte nur an Mitglieder weitergeben.
</DialogDescription>
</DialogHeader>
{load.status === "loading" || load.status === "idle" ? (
<div className="flex flex-col gap-2" aria-busy="true" aria-label="Links werden geladen">
{CALENDARS.map((c) => (
<Skeleton key={c.id} className="h-16 w-full" />
))}
</div>
) : load.status === "error" ? (
<div role="alert" className="flex flex-col items-start gap-3 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<p className="flex gap-2">
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0" />
Die Links konnten nicht geladen werden: {load.message}
</p>
<Button variant="outline" size="sm" onClick={() => void fetchLinks()}>
<RefreshCwIcon />
Erneut versuchen
</Button>
</div>
) : (
<ul className="divide-y rounded-xl border">
{CALENDARS.map((calendar) => {
const subscription = load.subscriptions.find((s) => s.calendar === calendar.slug);
return (
<li key={calendar.id} className="flex flex-col gap-2 p-3">
<span className="flex items-center gap-2 text-sm font-medium">
<span aria-hidden className={cn("size-2.5 rounded-full", calendar.dotClass)} />
{calendar.label}
</span>
{subscription ? (
<>
<code className="block truncate rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground select-all">
{subscription.icalUrl}
</code>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="xs" render={<a href={toWebcal(subscription.icalUrl)} />}>
Apple Kalender
</Button>
<Button
variant="outline"
size="xs"
render={<a href={toGoogleCalendar(subscription.icalUrl)} target="_blank" rel="noopener noreferrer" />}
>
Google Kalender
</Button>
<Button variant="outline" size="xs" onClick={() => void copy(subscription)}>
{copied === calendar.slug ? <CheckIcon /> : <CopyIcon />}
{copied === calendar.slug ? "Kopiert" : "Link kopieren"}
</Button>
</div>
</>
) : (
<p className="text-xs text-muted-foreground">
Für diesen Kalender ist auf dem Server kein Passwort eingerichtet, daher gibt es keinen Abo-Link.
</p>
)}
</li>
);
})}
</ul>
)}
<p className="text-xs text-muted-foreground">
Chor und Geburtstage teilen sich ein Passwort: Wird es geändert, müssen beide neu abonniert werden.
</p>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
);
}