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>
152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|