Files
Calendar_Frontend/components/admin/SubscriptionsDialog.tsx
PaddyandClaude Opus 5.5 0cda40f85e 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>
2026-09-28 22:15:59 +02:00

152 lines
6.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}