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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user