Files
Calendar_Frontend/components/admin/EventSheet.tsx
T
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

377 lines
14 KiB
TypeScript
Raw 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 { CircleAlertIcon, CopyIcon, LoaderCircleIcon, Trash2Icon, XIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { useAdminUser } from "@/components/admin/AdminSession";
import { EventForm, fieldId } from "@/components/admin/EventForm";
import { useMediaQuery } from "@/components/admin/useMediaQuery";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { goToAdminLogin } from "@/lib/admin-auth";
import { api } from "@/lib/api";
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
import { calendarById } from "@/lib/calendars";
import { formatDateTime } from "@/lib/dates";
import {
contentChanged,
duplicateForm,
eventFromPayload,
formFromEvent,
isFormDirty,
newEventForm,
toPayload,
validateEventForm,
type EventForm as EventFormState,
type EventFormField,
} from "@/lib/event-form";
import type { CalendarEvent } from "@/lib/types";
export type SheetMode =
| { kind: "new"; calendarId: number }
| { kind: "edit"; event: CalendarEvent }
| { kind: "duplicate"; event: CalendarEvent };
const TITLES: Record<SheetMode["kind"], string> = {
new: "Neuer Termin",
edit: "Termin bearbeiten",
duplicate: "Termin duplizieren",
};
// Top to bottom, so focus lands on the first problem the user would see.
const FIELD_ORDER: EventFormField[] = ["calendarId", "name", "startDate", "startTime", "endDate", "endTime", "url"];
/**
* Create, edit or duplicate one event. A bottom sheet on phones, a side panel
* from `sm` up.
*
* Nothing closes before the API has answered - the Angular row snapped shut
* the moment Save was clicked, so a failed save looked saved. On a failure the
* sheet stays open with the edits and says what went wrong; a 401 is the one
* failure that leaves, to the login page.
*
* The parent gives it a fresh `key` per opening, so state never leaks from one
* event into the next.
*/
export function EventSheet({
open,
mode,
now,
onClose,
onClosed,
onSaved,
onDeleted,
onDuplicate,
}: {
open: boolean;
mode: SheetMode | null;
now: Date;
/** Asked to close; the sheet has already dealt with unsaved changes. */
onClose: () => void;
/** The closing animation finished - safe to drop `mode`. */
onClosed: () => void;
onSaved: (event: CalendarEvent, previous: CalendarEvent | null) => void;
onDeleted: (event: CalendarEvent) => void;
onDuplicate: (event: CalendarEvent) => void;
}) {
const user = useAdminUser();
const desktop = useMediaQuery("(min-width: 640px)");
// What "unchanged" means. Moves forward after a partial save (moved, but the
// update failed), so a retry doesn't try to move again.
const [original, setOriginal] = useState<CalendarEvent | null>(mode?.kind === "edit" ? mode.event : null);
const [initial, setInitial] = useState<EventFormState | null>(() => (mode ? initialForm(mode, now) : null));
const [form, setForm] = useState<EventFormState | null>(initial);
const [showErrors, setShowErrors] = useState(false);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const errors = useMemo(() => (form ? validateEventForm(form) : {}), [form]);
const dirty = form !== null && initial !== null && isFormDirty(initial, form);
const busy = saving || deleting;
// Leaving the page with unsaved edits - reload, closing the tab - asks too.
useEffect(() => {
if (!open || !dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [open, dirty]);
const requestClose = () => {
if (busy) return;
if (dirty) {
setConfirmDiscard(true);
return;
}
onClose();
};
const leaveForLogin = () => {
toast.error("Deine Sitzung ist abgelaufen. Du wirst zur Anmeldung weitergeleitet …");
goToAdminLogin();
};
const save = async () => {
if (!form || !initial || !mode) return;
setShowErrors(true);
setSaveError(null);
const firstError = FIELD_ORDER.find((field) => errors[field]);
if (firstError) {
document.getElementById(fieldId(firstError))?.focus();
return;
}
const payload = toPayload(form);
setSaving(true);
let moved = false;
try {
if (!original) {
const eventId = await api.createEvent(payload);
onSaved(eventFromPayload(eventId, payload, user.fullName, new Date()), null);
toast.success(`„${payload.name}“ ist angelegt.`);
onClose();
return;
}
// Calendar first, then content: a move only rewrites the calendar, so if
// the second call fails the event is at least where it was sent, and the
// message can say exactly that.
if (payload.calendarId !== original.calendarId) {
await api.moveEvent(original.eventId, payload);
moved = true;
}
if (contentChanged(initial, form)) {
await api.updateEvent(original.eventId, payload);
}
onSaved(eventFromPayload(original.eventId, payload, user.fullName, new Date(), original), original);
toast.success(`„${payload.name}“ ist gespeichert.`);
onClose();
} catch (e) {
if (isSessionExpired(e)) {
leaveForLogin();
return;
}
if (moved && original) {
// Half done: record the move, keep the other edits on screen.
const movedEvent = { ...original, calendarId: payload.calendarId };
onSaved(movedEvent, original);
setOriginal(movedEvent);
setInitial({ ...initial, calendarId: payload.calendarId });
setSaveError(
`Der Termin wurde nach „${calendarById(payload.calendarId)?.label}“ verschoben, die übrigen Änderungen aber nicht gespeichert: ${describeApiError(e)}`,
);
} else {
setSaveError(describeApiError(e));
}
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!original) return;
setDeleting(true);
try {
await api.deleteEvent(original.eventId);
setConfirmDelete(false);
onDeleted(original);
toast.success(`„${original.name}“ ist gelöscht.`);
onClose();
} catch (e) {
if (isSessionExpired(e)) {
leaveForLogin();
return;
}
setConfirmDelete(false);
setSaveError(`Löschen fehlgeschlagen: ${describeApiError(e)}`);
} finally {
setDeleting(false);
}
};
return (
<>
<Sheet
open={open}
onOpenChange={(next) => {
if (!next) requestClose();
}}
onOpenChangeComplete={(isOpen) => {
if (!isOpen) onClosed();
}}
>
<SheetContent
side={desktop ? "right" : "bottom"}
showCloseButton={false}
className="gap-0 data-[side=bottom]:max-h-[92dvh] data-[side=bottom]:rounded-t-2xl data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
>
{mode && form && (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void save();
}}
className="flex min-h-0 flex-1 flex-col"
>
<SheetHeader className="flex-row items-start justify-between gap-3 border-b p-4">
<div className="flex flex-col gap-0.5">
<SheetTitle>{TITLES[mode.kind]}</SheetTitle>
<SheetDescription className={mode.kind === "edit" ? "line-clamp-1" : undefined}>
{mode.kind === "edit"
? mode.event.name
: mode.kind === "duplicate"
? `Kopie von „${mode.event.name}“ – wird als neuer Entwurf angelegt.`
: "Wird als Entwurf angelegt, wenn du den Status nicht änderst."}
</SheetDescription>
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={requestClose} aria-label="Schließen" disabled={busy}>
<XIcon />
</Button>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5">
<EventForm form={form} errors={showErrors ? errors : {}} disabled={busy} onChange={setForm} />
{original && <Metadata event={original} />}
</div>
<div className="flex flex-col gap-3 border-t p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
{saveError && (
<div role="alert" className="flex gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0" />
<p>{saveError}</p>
</div>
)}
{/* Labels follow the footer's own width (container query), not the window's: the
side panel is capped, so a wide window says nothing about the room in here. */}
<div className="@container flex items-center gap-2">
{original && (
<>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setConfirmDelete(true)}
disabled={busy}
aria-label="Löschen"
title="Löschen"
>
<Trash2Icon />
<span className="hidden @md:inline">Löschen</span>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onDuplicate(original)}
// Duplicating would silently drop the edits made so far.
disabled={busy || dirty}
title={dirty ? "Erst speichern oder verwerfen, dann duplizieren" : "Duplizieren"}
aria-label="Duplizieren"
>
<CopyIcon />
<span className="hidden @md:inline">Duplizieren</span>
</Button>
</>
)}
<div className="ml-auto flex gap-2">
<Button type="button" variant="outline" onClick={requestClose} disabled={busy}>
Abbrechen
</Button>
<Button type="submit" disabled={busy}>
{saving && <LoaderCircleIcon className="animate-spin" />}
{original ? "Speichern" : "Anlegen"}
</Button>
</div>
</div>
</div>
</form>
)}
</SheetContent>
</Sheet>
<AlertDialog open={confirmDiscard} onOpenChange={setConfirmDiscard}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Änderungen verwerfen?</AlertDialogTitle>
<AlertDialogDescription>Was du hier geändert hast, ist noch nicht gespeichert.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Weiter bearbeiten</AlertDialogCancel>
<Button
variant="destructive"
onClick={() => {
setConfirmDiscard(false);
onClose();
}}
>
Verwerfen
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={confirmDelete} onOpenChange={(next) => !deleting && setConfirmDelete(next)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>„{original?.name}“ löschen?</AlertDialogTitle>
<AlertDialogDescription>
Der Termin verschwindet aus dem Kalender und aus allen Abos. Das lässt sich hier nicht rückgängig machen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Abbrechen</AlertDialogCancel>
<Button variant="destructive" onClick={() => void remove()} disabled={deleting}>
{deleting ? <LoaderCircleIcon className="animate-spin" /> : <Trash2Icon />}
Löschen
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
function initialForm(mode: SheetMode, now: Date): EventFormState {
switch (mode.kind) {
case "new":
return newEventForm(now, mode.calendarId);
case "edit":
return formFromEvent(mode.event);
case "duplicate":
return duplicateForm(mode.event);
}
}
/** Who made and last changed the event. Legacy rows can lack either name. */
function Metadata({ event }: { event: CalendarEvent }) {
const line = (label: string, who: string, when: Date | null) =>
when || who ? (
<p>
{label}
{who && ` von ${who}`}
{when && ` · ${formatDateTime(when)}`}
</p>
) : null;
return (
<div className="mt-6 flex flex-col gap-0.5 border-t pt-4 text-xs text-muted-foreground">
{line("Erstellt", event.createdBy, event.created)}
{line("Zuletzt geändert", event.lastModifiedBy, event.lastModified)}
</div>
);
}