Files
Calendar_Frontend/components/admin/EventsToolbar.tsx
PaddyandClaude Opus 5.5 0cda40f85e 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>
2026-09-28 22:15:59 +02:00

152 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}