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

+21
View File
@@ -0,0 +1,21 @@
"use client";
import { createContext, useContext } from "react";
import type { AdminUser } from "@/lib/types";
/**
* The signed-in account, for components below the admin layout. The layout
* renders its children only once /me has answered, so inside the admin area
* this is never null.
*/
const AdminSessionContext = createContext<AdminUser | null>(null);
export const AdminSessionProvider = AdminSessionContext.Provider;
export const useAdminUser = (): AdminUser => {
const user = useContext(AdminSessionContext);
if (!user) {
throw new Error("useAdminUser() used outside the admin layout");
}
return user;
};
+19
View File
@@ -0,0 +1,19 @@
import { calendarById } from "@/lib/calendars";
import { cn } from "@/lib/utils";
/** The calendar an event belongs to, in that calendar's colour - never the bare id. */
export function CalendarBadge({ calendarId, className }: { calendarId: number; className?: string }) {
const calendar = calendarById(calendarId);
return (
<span
className={cn(
"inline-flex h-5 items-center gap-1.5 rounded-full px-2 text-xs font-medium ring-1 ring-inset",
calendar?.badgeClass ?? "bg-muted text-muted-foreground ring-border",
className,
)}
>
<span aria-hidden className={cn("size-1.5 rounded-full", calendar?.dotClass ?? "bg-muted-foreground")} />
{calendar?.label ?? `Kalender ${calendarId}`}
</span>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { CircleAlertIcon, LoaderCircleIcon } from "lucide-react";
import type { CalendarLoad } from "@/components/admin/useEvents";
import { CALENDARS } from "@/lib/calendars";
import { cn } from "@/lib/utils";
/**
* One toggle per calendar. A row that scrolls sideways on phones rather than
* wrapping into three lines of chrome above the list; wraps on wider screens.
*/
export function CalendarChips({
selected,
counts,
loads,
onToggle,
}: {
selected: readonly number[];
counts: Record<number, number>;
loads: Record<number, CalendarLoad>;
onToggle: (calendarId: number) => void;
}) {
return (
<div
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden"
role="group"
aria-label="Kalender"
>
{CALENDARS.map((calendar) => {
const on = selected.includes(calendar.id);
const load = loads[calendar.id];
return (
<button
key={calendar.id}
type="button"
aria-pressed={on}
onClick={() => onToggle(calendar.id)}
title={load?.status === "error" ? `${calendar.label} konnte nicht geladen werden: ${load.message}` : undefined}
className={cn(
"inline-flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/30",
on
? "border-foreground/15 bg-foreground/[0.04] font-medium text-foreground"
: "border-border text-muted-foreground hover:bg-muted",
)}
>
<span
aria-hidden
className={cn("size-2.5 rounded-full", on ? calendar.dotClass : "ring-1 ring-muted-foreground/50 ring-inset")}
/>
{calendar.label}
{load?.status === "loading" ? (
<LoaderCircleIcon aria-label="lädt" className="size-3.5 animate-spin text-muted-foreground" />
) : load?.status === "error" ? (
<CircleAlertIcon aria-label="Fehler" className="size-3.5 text-destructive" />
) : (
<span className="text-xs text-muted-foreground tabular-nums">{counts[calendar.id] ?? 0}</span>
)}
</button>
);
})}
</div>
);
}
+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>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { CalendarX2Icon, SearchXIcon } from "lucide-react";
import { Fragment } from "react";
import { EventListItem } from "@/components/admin/EventListItem";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDay } from "@/lib/dates";
import { groupByMonth, type Range, type Sort } from "@/lib/filter";
import type { CalendarEvent } from "@/lib/types";
/**
* The list itself: grouped by month when sorted by start (what people scan
* for), flat when sorted by creation date (where month headings would repeat
* and mislead).
*/
export function EventList({
events,
now,
sort,
range,
query,
loading,
noCalendars,
publishingId,
todayMarker,
onOpen,
onPublish,
onClearQuery,
}: {
events: CalendarEvent[];
now: Date;
sort: Sort;
range: Range;
query: string;
loading: boolean;
noCalendars: boolean;
publishingId: number | null;
/** Index into `events` to show the "Heute" marker in front of (`events.length` = after the last); null for none. */
todayMarker: number | null;
onOpen?: (event: CalendarEvent) => void;
onPublish: (event: CalendarEvent) => void;
onClearQuery: () => void;
}) {
if (noCalendars) {
return <Empty icon={<CalendarX2Icon />} title="Kein Kalender ausgewählt" text="Wähle oben mindestens einen Kalender." />;
}
if (events.length === 0 && loading) {
return <ListSkeleton />;
}
if (events.length === 0) {
if (query.trim() !== "") {
return (
<Empty
icon={<SearchXIcon />}
title={`Keine Termine für „${query.trim()}“`}
text="Die Suche berücksichtigt Titel und Ort."
action={
<Button variant="outline" size="sm" onClick={onClearQuery}>
Suche leeren
</Button>
}
/>
);
}
const text = { future: "Keine kommenden Termine.", past: "Keine vergangenen Termine.", all: "Noch keine Termine." }[range];
return <Empty icon={<CalendarX2Icon />} title={text} text="In den ausgewählten Kalendern ist nichts eingetragen." />;
}
const markerBefore = todayMarker !== null && todayMarker < events.length ? events[todayMarker].eventId : null;
const marker = <TodayMarker now={now} />;
const item = (event: CalendarEvent) => (
<Fragment key={event.eventId}>
{event.eventId === markerBefore && marker}
<EventListItem
event={event}
now={now}
publishing={publishingId === event.eventId}
onOpen={onOpen}
onPublish={onPublish}
/>
</Fragment>
);
if (sort.startsWith("created")) {
return <ul className="divide-y overflow-hidden rounded-xl border bg-card">{events.map(item)}</ul>;
}
return (
<div className="flex flex-col gap-5">
{groupByMonth(events, now).map((group, i) => (
<section key={`${group.key}-${i}`} aria-label={group.label}>
<h2 className="sticky top-[calc(3.5rem+env(safe-area-inset-top))] z-10 -mx-1 bg-background/95 px-1 py-2 text-sm font-semibold text-muted-foreground backdrop-blur">
{group.label}
</h2>
<ul className="divide-y overflow-hidden rounded-xl border bg-card">{group.events.map(item)}</ul>
</section>
))}
{todayMarker === events.length && <ul className="overflow-hidden rounded-xl border">{marker}</ul>}
</div>
);
}
/** Where today falls in the list. The page scrolls it into view in the "Alle" range. */
function TodayMarker({ now }: { now: Date }) {
return (
<li
id="heute"
className="flex items-center gap-2 bg-primary/[0.05] px-4 py-1.5 text-xs font-semibold text-primary"
>
<span aria-hidden className="size-1.5 rounded-full bg-primary" />
Heute · {formatDay(now)}
</li>
);
}
function Empty({ icon, title, text, action }: { icon: React.ReactNode; title: string; text: string; action?: React.ReactNode }) {
return (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center">
<div className="text-muted-foreground [&_svg]:size-8">{icon}</div>
<p className="font-medium">{title}</p>
<p className="text-sm text-muted-foreground">{text}</p>
{action && <div className="mt-2">{action}</div>}
</div>
);
}
function ListSkeleton() {
return (
<div className="flex flex-col gap-3" aria-busy="true" aria-label="Termine werden geladen">
<Skeleton className="h-5 w-32" />
<div className="divide-y rounded-xl border">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="flex gap-4 p-4">
<Skeleton className="h-12 w-11" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-5 w-24" />
</div>
</div>
))}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { LoaderCircleIcon, MapPinIcon, RepeatIcon, SendIcon } from "lucide-react";
import { CalendarBadge } from "@/components/admin/CalendarBadge";
import { Button } from "@/components/ui/button";
import { dateBlock, describeWhen, isPast, occurrence, repeatLabel } from "@/lib/event-display";
import type { CalendarEvent } from "@/lib/types";
import { cn } from "@/lib/utils";
/**
* One event in the list. The whole row opens the event (a stretched button on
* the title, so the row's other button - "Veröffentlichen" - stays a separate,
* valid control rather than a button inside a button).
*/
export function EventListItem({
event,
now,
publishing,
onOpen,
onPublish,
}: {
event: CalendarEvent;
now: Date;
publishing: boolean;
onOpen?: (event: CalendarEvent) => void;
onPublish: (event: CalendarEvent) => void;
}) {
const { start } = occurrence(event, now);
const block = dateBlock(start);
const repeat = repeatLabel(event.repeatFrequency);
const title = event.name || "(ohne Titel)";
return (
<li
className={cn(
"relative flex gap-3 px-3 py-3 transition-colors sm:gap-4 sm:px-4",
onOpen && "hover:bg-muted/50 has-[button[data-open]:focus-visible]:bg-muted/50",
isPast(event, now) && "opacity-60",
)}
>
<div className="flex w-11 shrink-0 flex-col items-center pt-0.5 text-center leading-tight" aria-hidden>
<span className="text-[11px] font-medium text-muted-foreground uppercase">{block.weekday}</span>
<span className="text-xl font-semibold tabular-nums">{block.day}</span>
<span className="text-[11px] text-muted-foreground">{block.month}</span>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
{onOpen ? (
<button
type="button"
data-open
onClick={() => onOpen(event)}
className="text-left font-medium outline-none after:absolute after:inset-0 after:content-['']"
>
<span className="line-clamp-2">{title}</span>
</button>
) : (
<span className="line-clamp-2 font-medium">{title}</span>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm text-muted-foreground">
<span>{describeWhen(event, now)}</span>
{event.location && (
<span className="inline-flex min-w-0 items-center gap-1">
<MapPinIcon aria-hidden className="size-3.5 shrink-0" />
<span className="truncate">{event.location}</span>
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<CalendarBadge calendarId={event.calendarId} />
{event.status === "DRAFT" && (
<span className="inline-flex h-5 items-center rounded-full border border-dashed border-amber-400 px-2 text-xs font-medium text-amber-800">
Entwurf
</span>
)}
{event.status === "PRIVATE" && (
<span className="inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs font-medium text-muted-foreground">
Privat
</span>
)}
{repeat && (
<span className="inline-flex h-5 items-center gap-1 rounded-full bg-muted px-2 text-xs text-muted-foreground">
<RepeatIcon aria-hidden className="size-3" />
{repeat}
</span>
)}
{event.status === "DRAFT" && (
<Button
variant="outline"
size="xs"
className="relative z-10 ml-auto"
disabled={publishing}
onClick={() => onPublish(event)}
>
{publishing ? <LoaderCircleIcon className="animate-spin" /> : <SendIcon />}
Veröffentlichen
</Button>
)}
</div>
</div>
</li>
);
}
+376
View File
@@ -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>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { ArrowDownUpIcon, RefreshCwIcon, SearchIcon, XIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import type { Range, Sort } from "@/lib/filter";
import { cn } from "@/lib/utils";
const RANGE_LABELS: Record<Range, string> = { future: "Kommend", past: "Vergangen", all: "Alle" };
const SORT_LABELS: Record<Sort, string> = {
start_asc: "Beginn – früheste zuerst",
start_desc: "Beginn – späteste zuerst",
created_asc: "Erstellt – älteste zuerst",
created_desc: "Erstellt – neueste zuerst",
};
/**
* Search, sort, reload and the time range. The search box keeps its own text
* and writes it to the URL after a short pause, so typing doesn't replace the
* URL on every keystroke.
*/
export function EventsToolbar({
query,
range,
sort,
loading,
onQueryChange,
onRangeChange,
onSortChange,
onReload,
}: {
query: string;
range: Range;
sort: Sort;
loading: boolean;
onQueryChange: (query: string) => void;
onRangeChange: (range: Range) => void;
onSortChange: (sort: Sort) => void;
onReload: () => void;
}) {
const [text, setText] = useState(query);
// Take over a query that changed from outside (the empty state's "Suche
// leeren", the back button) - but not our own write coming back through the
// URL, which would drop whatever was typed while it travelled.
const [seenQuery, setSeenQuery] = useState(query);
const [emitted, setEmitted] = useState(query);
if (query !== seenQuery) {
setSeenQuery(query);
if (query !== emitted) {
setText(query);
}
}
const onQueryChangeRef = useRef(onQueryChange);
useEffect(() => {
onQueryChangeRef.current = onQueryChange;
});
useEffect(() => {
if (text === query) return;
const timer = setTimeout(() => {
setEmitted(text);
onQueryChangeRef.current(text);
}, 250);
return () => clearTimeout(timer);
}, [text, query]);
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon aria-hidden className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Titel oder Ort suchen"
aria-label="Termine durchsuchen"
className="pr-9 pl-9 [&::-webkit-search-cancel-button]:hidden"
/>
{text !== "" && (
<button
type="button"
onClick={() => {
setText("");
onQueryChange("");
}}
aria-label="Suche leeren"
className="absolute top-1/2 right-2 flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground hover:bg-muted"
>
<XIcon className="size-3.5" />
</button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="Sortierung" />}>
<ArrowDownUpIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel>Sortieren nach</DropdownMenuLabel>
<DropdownMenuRadioGroup value={sort} onValueChange={(value) => onSortChange(value as Sort)}>
{(Object.keys(SORT_LABELS) as Sort[]).map((key) => (
// Base UI keeps a menu open after a radio pick by default; one choice is all there is here.
<DropdownMenuRadioItem key={key} value={key} closeOnClick>
{SORT_LABELS[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="icon" onClick={onReload} aria-label="Neu laden" disabled={loading}>
<RefreshCwIcon className={cn(loading && "animate-spin")} />
</Button>
</div>
<ToggleGroup
value={[range]}
onValueChange={(value) => {
// A segmented control: clicking the pressed item must not leave nothing selected.
const next = value[0] as Range | undefined;
if (next) onRangeChange(next);
}}
variant="outline"
spacing={0}
className="grid w-full grid-cols-3 sm:inline-grid sm:w-auto"
aria-label="Zeitraum"
>
{(Object.keys(RANGE_LABELS) as Range[]).map((key) => (
<ToggleGroupItem key={key} value={key} className="aria-pressed:bg-foreground/[0.06] aria-pressed:font-semibold">
{RANGE_LABELS[key]}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { LoaderCircleIcon, SendIcon } from "lucide-react";
import { CalendarBadge } from "@/components/admin/CalendarBadge";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { calendarById } from "@/lib/calendars";
import { formatDay } from "@/lib/dates";
import { describeWhen, occurrence } from "@/lib/event-display";
import type { CalendarEvent } from "@/lib/types";
/**
* "Really publish?" - publishing puts the event into every subscriber's
* calendar app, and for the public calendar onto nachklang.art, which is not
* something to do by a stray tap on a phone.
*
* Stays open with a spinner until the API has answered, and stays open on a
* failure (the page shows the error as a toast), so a retry is one tap.
*/
export function PublishDialog({
event,
now,
publishing,
onConfirm,
onCancel,
}: {
event: CalendarEvent | null;
now: Date;
publishing: boolean;
onConfirm: (event: CalendarEvent) => void;
onCancel: () => void;
}) {
const calendar = event ? calendarById(event.calendarId) : undefined;
return (
<AlertDialog
open={event !== null}
onOpenChange={(open) => {
if (!open && !publishing) onCancel();
}}
>
<AlertDialogContent>
{event && (
<>
<AlertDialogHeader>
<AlertDialogTitle>„{event.name}“ veröffentlichen?</AlertDialogTitle>
<AlertDialogDescription>
{calendar?.slug === "public"
? "Der Termin wird danach öffentlich angezeigt – auf nachklang.art und bei allen, die den Kalender abonniert haben."
: `Der Termin erscheint danach bei allen, die den Kalender „${calendar?.label ?? event.calendarId}“ abonniert haben.`}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-1.5 rounded-lg bg-muted/60 px-3 py-2.5 text-sm">
<span>
{formatDay(occurrence(event, now).start)} · {describeWhen(event, now)}
</span>
<CalendarBadge calendarId={event.calendarId} className="self-start" />
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>Abbrechen</AlertDialogCancel>
<Button onClick={() => onConfirm(event)} disabled={publishing}>
{publishing ? <LoaderCircleIcon className="animate-spin" /> : <SendIcon />}
Veröffentlichen
</Button>
</AlertDialogFooter>
</>
)}
</AlertDialogContent>
</AlertDialog>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { CalendarPlusIcon, CheckIcon, CircleAlertIcon, CopyIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { goToAdminLogin } from "@/lib/admin-auth";
import { api } from "@/lib/api";
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
import { CALENDARS, type CalendarSlug } from "@/lib/calendars";
import { toGoogleCalendar, toWebcal } from "@/lib/public-calendar";
import { cn } from "@/lib/utils";
type Subscription = { calendar: CalendarSlug; icalUrl: string };
type Load = { status: "idle" } | { status: "loading" } | { status: "ready"; subscriptions: Subscription[] } | { status: "error"; message: string };
/**
* Subscription links for every calendar, for the editor's own phone or to
* pass on to members. The URLs of the internal calendars carry their shared
* password, which is why they come from the API (GET
* /calendar/events/subscriptions, editors only) rather than being built here -
* the browser has no other way to know the passwords.
*
* Fetched each time the dialog opens, so a rotated password shows up at once.
*/
export function SubscriptionsDialog() {
const [load, setLoad] = useState<Load>({ status: "idle" });
const [copied, setCopied] = useState<CalendarSlug | null>(null);
const fetchLinks = async () => {
setLoad({ status: "loading" });
try {
setLoad({ status: "ready", subscriptions: await api.getSubscriptions() });
} catch (e) {
if (isSessionExpired(e)) {
goToAdminLogin();
return;
}
setLoad({ status: "error", message: describeApiError(e) });
}
};
const copy = async (subscription: Subscription) => {
try {
await navigator.clipboard.writeText(subscription.icalUrl);
setCopied(subscription.calendar);
setTimeout(() => setCopied((current) => (current === subscription.calendar ? null : current)), 2000);
} catch {
// No clipboard access: the URL is selectable in the dialog instead.
setCopied(null);
}
};
return (
<Dialog
onOpenChange={(open) => {
if (open) void fetchLinks();
}}
>
<DialogTrigger render={<Button variant="ghost" size="sm" aria-label="Abonnieren" title="Kalender abonnieren" />}>
<CalendarPlusIcon />
<span className="hidden sm:inline">Abonnieren</span>
</DialogTrigger>
{/* minmax(0,1fr): an implicit grid column grows to its longest unbreakable content - here a
full URL - so `truncate` on the URL never kicked in and the dialog's content overflowed. */}
<DialogContent className="max-h-[90dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Kalender abonnieren</DialogTitle>
<DialogDescription>
Mit diesen Links erscheinen die Termine automatisch in einer Kalender-App. Die Links der internen Kalender
enthalten das jeweilige Kalender-Passwort – bitte nur an Mitglieder weitergeben.
</DialogDescription>
</DialogHeader>
{load.status === "loading" || load.status === "idle" ? (
<div className="flex flex-col gap-2" aria-busy="true" aria-label="Links werden geladen">
{CALENDARS.map((c) => (
<Skeleton key={c.id} className="h-16 w-full" />
))}
</div>
) : load.status === "error" ? (
<div role="alert" className="flex flex-col items-start gap-3 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<p className="flex gap-2">
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0" />
Die Links konnten nicht geladen werden: {load.message}
</p>
<Button variant="outline" size="sm" onClick={() => void fetchLinks()}>
<RefreshCwIcon />
Erneut versuchen
</Button>
</div>
) : (
<ul className="divide-y rounded-xl border">
{CALENDARS.map((calendar) => {
const subscription = load.subscriptions.find((s) => s.calendar === calendar.slug);
return (
<li key={calendar.id} className="flex flex-col gap-2 p-3">
<span className="flex items-center gap-2 text-sm font-medium">
<span aria-hidden className={cn("size-2.5 rounded-full", calendar.dotClass)} />
{calendar.label}
</span>
{subscription ? (
<>
<code className="block truncate rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground select-all">
{subscription.icalUrl}
</code>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="xs" render={<a href={toWebcal(subscription.icalUrl)} />}>
Apple Kalender
</Button>
<Button
variant="outline"
size="xs"
render={<a href={toGoogleCalendar(subscription.icalUrl)} target="_blank" rel="noopener noreferrer" />}
>
Google Kalender
</Button>
<Button variant="outline" size="xs" onClick={() => void copy(subscription)}>
{copied === calendar.slug ? <CheckIcon /> : <CopyIcon />}
{copied === calendar.slug ? "Kopiert" : "Link kopieren"}
</Button>
</div>
</>
) : (
<p className="text-xs text-muted-foreground">
Für diesen Kalender ist auf dem Server kein Passwort eingerichtet, daher gibt es keinen Abo-Link.
</p>
)}
</li>
);
})}
</ul>
)}
<p className="text-xs text-muted-foreground">
Chor und Geburtstage teilen sich ein Passwort: Wird es geändert, müssen beide neu abonniert werden.
</p>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { goToAdminLogin } from "@/lib/admin-auth";
import { api } from "@/lib/api";
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
import { CALENDARS, calendarById } from "@/lib/calendars";
import type { CalendarEvent } from "@/lib/types";
export type CalendarLoad = { status: "loading" } | { status: "ready" } | { status: "error"; message: string };
const initialLoads = (): Record<number, CalendarLoad> =>
Object.fromEntries(CALENDARS.map((c) => [c.id, { status: "loading" } as CalendarLoad]));
/**
* Every calendar's events, loaded in parallel - there is no endpoint that
* returns them all at once (docs/api-wishlist.md).
*
* Each calendar loads and fails on its own: one calendar the API cannot serve
* shows an error on its chip instead of blanking the whole list. The admin feed
* returns exactly the calendar's own events (it ignores `includes_calendars`),
* so a calendar's slice of the list can be replaced by calendarId.
*
* A 401 on any of them means the session is gone, and goes to the login page.
*/
export function useEvents() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loads, setLoads] = useState<Record<number, CalendarLoad>>(initialLoads);
// Per-calendar request counter, so a slow response cannot overwrite a newer one.
const generation = useRef<Record<number, number>>({});
const fetchCalendar = useCallback(async (calendarId: number) => {
const calendar = calendarById(calendarId);
if (!calendar) return;
const mine = (generation.current[calendarId] = (generation.current[calendarId] ?? 0) + 1);
try {
const loaded = await api.getEvents(calendar.slug);
if (generation.current[calendarId] !== mine) return;
setEvents((current) => [...current.filter((e) => e.calendarId !== calendarId), ...loaded]);
setLoads((current) => ({ ...current, [calendarId]: { status: "ready" } }));
} catch (e) {
if (generation.current[calendarId] !== mine) return;
if (isSessionExpired(e)) {
goToAdminLogin();
return;
}
setLoads((current) => ({ ...current, [calendarId]: { status: "error", message: describeApiError(e) } }));
}
}, []);
useEffect(() => {
for (const calendar of CALENDARS) {
void fetchCalendar(calendar.id);
}
}, [fetchCalendar]);
/** Reload one calendar, or all. Shows the loading state; existing events stay visible meanwhile. */
const reload = useCallback(
(calendarId?: number) => {
const ids = calendarId === undefined ? CALENDARS.map((c) => c.id) : [calendarId];
setLoads((current) => ({ ...current, ...Object.fromEntries(ids.map((id) => [id, { status: "loading" }])) }));
for (const id of ids) {
void fetchCalendar(id);
}
},
[fetchCalendar],
);
/** Re-fetch without flagging the calendar as loading - after a write, to pick up server-side fields. */
const refresh = useCallback((calendarId: number) => void fetchCalendar(calendarId), [fetchCalendar]);
/** Put a saved event into the list (new or changed), replacing any copy with the same id. */
const upsertEvent = useCallback((event: CalendarEvent) => {
setEvents((current) => [...current.filter((e) => e.eventId !== event.eventId), event]);
}, []);
const removeEvent = useCallback((eventId: number) => {
setEvents((current) => current.filter((e) => e.eventId !== eventId));
}, []);
return { events, loads, reload, refresh, upsertEvent, removeEvent };
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
import { parseListState, toQueryString, type ListState } from "@/lib/url-state";
/**
* The list's filters, read from and written to the query string. `replace`
* rather than `push`: flipping a filter should not fill the back button's
* history with one entry per click.
*
* Needs a <Suspense> boundary above it - useSearchParams() in a prerendered
* page opts the subtree into client-side rendering.
*/
export function useListState(): [ListState, (patch: Partial<ListState>) => void] {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const state = useMemo(() => parseListState(new URLSearchParams(searchParams.toString())), [searchParams]);
const update = useCallback(
(patch: Partial<ListState>) => {
const query = toQueryString({ ...state, ...patch });
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
},
[state, router, pathname],
);
return [state, update];
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useSyncExternalStore } from "react";
/** Whether a CSS media query matches, kept current. False during prerendering. */
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
const list = window.matchMedia(query);
list.addEventListener("change", onChange);
return () => list.removeEventListener("change", onChange);
},
() => window.matchMedia(query).matches,
() => false,
);
}