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>
259 lines
8.7 KiB
TypeScript
259 lines
8.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|