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

+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>
);
}