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