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>
152 lines
6.4 KiB
TypeScript
152 lines
6.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|