Files
Calendar_Frontend/components/admin/EventForm.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

259 lines
8.7 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 { 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>
);
}