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

+86
View File
@@ -0,0 +1,86 @@
/**
* Shapes on the wire and in the app.
*
* `ApiEvent` is what `GET /calendar/events/:calendar/json` actually returns,
* which is looser than the API's own `Event` interface claims: the service maps
* raw MariaDB rows straight through (`toEvent` in events.service.ts), so
* `wholeDay` arrives as whatever the driver produced for the column (1/0 rather
* than true/false is the likely case), and a deleted event's latest version is
* a row with only `status` set - every other version column is null. `status`
* is missing entirely from the anonymous public listing.
*
* `CalendarEvent` is the parsed, trustworthy form everything else in the app
* works with. Parsing happens once, in `parseApiEvent`.
*/
export type EventStatus = "PUBLIC" | "PRIVATE" | "DRAFT" | "DELETED";
export interface ApiEvent {
eventId: number;
calendarId: number;
uuid: string;
name: string | null;
description: string | null;
startDateTime: string | null;
endDateTime: string | null;
createdDate: string | null;
lastModifiedDate?: string | null;
location: string | null;
createdBy?: string | null;
lastModifiedBy?: string | null;
url: string | null;
wholeDay: boolean | number | null;
repeatFrequency: string | null;
status?: string | null;
}
export interface CalendarEvent {
eventId: number;
calendarId: number;
uuid: string;
name: string;
description: string;
/** Instants. Wall-clock meaning is always Europe/Berlin, see lib/dates.ts. */
start: Date;
end: Date;
created: Date | null;
lastModified: Date | null;
location: string;
createdBy: string;
lastModifiedBy: string;
url: string;
wholeDay: boolean;
/** "" when the event does not repeat. Sent to iCal verbatim as RRULE:FREQ=… */
repeatFrequency: string;
status: EventStatus;
}
/**
* The body of POST /calendar/events, PUT /calendar/events/:id and
* PUT /calendar/events/move/:id.
*
* Every field is required here on purpose. PUT replaces the whole version and
* defaults whatever is missing - `repeatFrequency ?? ''`, `status ?? 'PUBLIC'`
* (events.router.ts) - so a partial body silently drops a birthday's
* recurrence or publishes a draft.
*/
export interface EventPayload {
calendarId: number;
name: string;
description: string;
startDateTime: string;
endDateTime: string;
location: string;
url: string;
wholeDay: boolean;
repeatFrequency: string;
status: EventStatus;
}
/** GET /admin/me - the signed-in account, shared by all Nachklang apps. */
export interface AdminUser {
id: string;
email: string;
fullName: string;
apps: string[];
}