Files
Calendar_Frontend/components/admin/EventList.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

148 lines
4.8 KiB
TypeScript

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