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:
PaddyandClaude Opus 5.5 committed 2026-09-28 22:15:59 +02:00
1 parent f82255961b
commit 0cda40f85e
138 files changed
+15942 -24010

No files matched your search

+258
View File
@@ -0,0 +1,258 @@
"use client";
import { RepeatIcon } from "lucide-react";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CALENDARS, calendarById } from "@/lib/calendars";
import { repeatLabel } from "@/lib/event-display";
import { setCalendar, setStart, type EventForm as EventFormState, type EventFormErrors } from "@/lib/event-form";
import { cn } from "@/lib/utils";
/** Element ids, so the sheet can focus the first field with an error. */
export const fieldId = (field: keyof EventFormState): string => `event-${field}`;
const CALENDAR_ITEMS = CALENDARS.map((c) => ({ value: String(c.id), label: c.label }));
/**
* The event's fields. Presentational: every rule (end follows start, the
* birthday recurrence, validation) lives in lib/event-form.ts, and the sheet
* owns the state.
*
* Inputs are the browser's native date/time pickers, which are the good ones
* on phones. The URL field is plain text with a URL keyboard rather than
* type="url", whose built-in validation would reject "nachklang.art" before
* normaliseUrl() could accept it.
*/
export function EventForm({
form,
errors,
disabled,
onChange,
}: {
form: EventFormState;
/** Only the errors to show - empty until the first save attempt. */
errors: EventFormErrors;
disabled: boolean;
onChange: (form: EventFormState) => void;
}) {
const set = <K extends keyof EventFormState>(key: K, value: EventFormState[K]) => onChange({ ...form, [key]: value });
const forcedRepeat = calendarById(form.calendarId)?.forcedRepeatFrequency;
const invalid = (field: keyof EventFormState) => (errors[field] ? true : undefined);
return (
<FieldGroup className="gap-5">
<Field data-invalid={invalid("calendarId")}>
<FieldLabel htmlFor={fieldId("calendarId")}>Kalender</FieldLabel>
<Select
items={CALENDAR_ITEMS}
value={form.calendarId ? String(form.calendarId) : null}
onValueChange={(value) => value && onChange(setCalendar(form, Number(value)))}
disabled={disabled}
>
<SelectTrigger id={fieldId("calendarId")} className="w-full" aria-invalid={invalid("calendarId")}>
<SelectValue placeholder="Kalender wählen" />
</SelectTrigger>
<SelectContent>
{CALENDARS.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
<span aria-hidden className={cn("size-2 rounded-full", c.dotClass)} />
{c.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError>{errors.calendarId}</FieldError>
</Field>
<Field data-invalid={invalid("name")}>
<FieldLabel htmlFor={fieldId("name")}>Titel</FieldLabel>
<Input
id={fieldId("name")}
value={form.name}
onChange={(e) => set("name", e.target.value)}
disabled={disabled}
aria-invalid={invalid("name")}
autoComplete="off"
/>
<FieldError>{errors.name}</FieldError>
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor={fieldId("wholeDay")} className="font-normal">
Ganztägig
</FieldLabel>
<Switch
id={fieldId("wholeDay")}
checked={form.wholeDay}
onCheckedChange={(checked) => set("wholeDay", checked)}
disabled={disabled}
/>
</Field>
<DateTimeRow
label="Beginn"
dateField="startDate"
timeField="startTime"
date={form.startDate}
time={form.startTime}
showTime={!form.wholeDay}
errors={errors}
disabled={disabled}
onDate={(date) => onChange(setStart(form, date, form.startTime))}
onTime={(time) => onChange(setStart(form, form.startDate, time))}
/>
<DateTimeRow
label="Ende"
dateField="endDate"
timeField="endTime"
date={form.endDate}
time={form.endTime}
showTime={!form.wholeDay}
errors={errors}
disabled={disabled}
onDate={(date) => set("endDate", date)}
onTime={(time) => set("endTime", time)}
/>
{form.repeatFrequency && (
<p className="-mt-1 flex items-center gap-2 text-sm text-muted-foreground">
<RepeatIcon aria-hidden className="size-4 shrink-0" />
{forcedRepeat
? `Wiederholt sich ${repeatLabel(form.repeatFrequency)} – das gilt für alle Termine im Kalender „${calendarById(form.calendarId)?.label}“.`
: `Wiederholt sich ${repeatLabel(form.repeatFrequency)}.`}
</p>
)}
<Field>
<FieldLabel htmlFor={fieldId("location")}>Ort</FieldLabel>
<Input
id={fieldId("location")}
value={form.location}
onChange={(e) => set("location", e.target.value)}
disabled={disabled}
/>
</Field>
<Field data-invalid={invalid("url")}>
<FieldLabel htmlFor={fieldId("url")}>Link</FieldLabel>
<Input
id={fieldId("url")}
value={form.url}
onChange={(e) => set("url", e.target.value)}
disabled={disabled}
aria-invalid={invalid("url")}
inputMode="url"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
placeholder="z. B. nachklang.art/konzert"
/>
<FieldError>{errors.url}</FieldError>
</Field>
<Field>
<FieldLabel htmlFor={fieldId("description")}>Beschreibung</FieldLabel>
<Textarea
id={fieldId("description")}
value={form.description}
onChange={(e) => set("description", e.target.value)}
disabled={disabled}
rows={4}
/>
</Field>
<Field>
<FieldLabel>Status</FieldLabel>
{form.status === "PRIVATE" ? (
<FieldDescription>Privat – dieser Status wird beim Speichern beibehalten.</FieldDescription>
) : (
<>
<ToggleGroup
value={[form.status]}
onValueChange={(value) => {
const next = value[0];
if (next === "DRAFT" || next === "PUBLIC") set("status", next);
}}
variant="outline"
spacing={0}
disabled={disabled}
className="grid w-full grid-cols-2"
aria-label="Status"
>
<ToggleGroupItem value="DRAFT" className="aria-pressed:bg-foreground/[0.06] aria-pressed:font-semibold">
Entwurf
</ToggleGroupItem>
<ToggleGroupItem value="PUBLIC" className="aria-pressed:bg-foreground/[0.06] aria-pressed:font-semibold">
Veröffentlicht
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
{form.status === "DRAFT"
? "Entwürfe sieht nur, wer hier angemeldet ist – nicht in Abos und nicht auf der Website."
: "Veröffentlichte Termine erscheinen in den Kalender-Abos."}
</FieldDescription>
</>
)}
</Field>
</FieldGroup>
);
}
function DateTimeRow({
label,
dateField,
timeField,
date,
time,
showTime,
errors,
disabled,
onDate,
onTime,
}: {
label: string;
dateField: "startDate" | "endDate";
timeField: "startTime" | "endTime";
date: string;
time: string;
showTime: boolean;
errors: EventFormErrors;
disabled: boolean;
onDate: (date: string) => void;
onTime: (time: string) => void;
}) {
const error = errors[dateField] ?? (showTime ? errors[timeField] : undefined);
return (
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor={fieldId(dateField)}>{label}</FieldLabel>
<div className="flex gap-2">
<Input
id={fieldId(dateField)}
type="date"
value={date}
onChange={(e) => onDate(e.target.value)}
disabled={disabled}
aria-invalid={errors[dateField] ? true : undefined}
className="min-w-0 flex-1"
/>
{showTime && (
<Input
id={fieldId(timeField)}
type="time"
value={time}
onChange={(e) => onTime(e.target.value)}
disabled={disabled}
aria-invalid={errors[timeField] ? true : undefined}
aria-label={`${label} Uhrzeit`}
className="w-28 shrink-0"
/>
)}
</div>
<FieldError>{error}</FieldError>
</Field>
);
}