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