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