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>
5.4 KiB
Architecture
Next.js 16 (App Router) + React 19 + TypeScript + Tailwind 4 + shadcn (preset b7ClO5que, Base UI
primitives) - the same stack as nachklang-feedback. Rewritten from Angular in September 2026; the
decisions behind it are in rewrite-plan.md.
Routes
| Path | What | Rendering |
|---|---|---|
/ |
Public landing page: subscribe links + next public events | Server component, static, revalidate = 300 |
/admin |
Event management (list + edit sheet) | Client, behind proxy.ts and the /me gate |
/admin/login |
Forwarder to the admin app's login | Client |
Authentication
This app has no login and no credential of its own. The session is an httpOnly cookie
(__Secure-nachklang.session_token in production, nachklang.session_token over http in dev) that
the API sets on .nachklang.art; it is shared by calendar, feedback, tickets and admin.
proxy.ts- a convenience gate on/admin/*: no session cookie →/admin/login?redirect=…. It only sees the cookie's presence, never its validity.app/admin/login/page.tsxforwards to${NEXT_PUBLIC_ADMIN_APP_URL}/login?redirect=<absolute URL>.app/admin/layout.tsxcallsGET /admin/meand decides:- 401 → login (the only status that redirects);
- 403, or 200 without
"calendar"inapps→ "Kein Zugriff" (no redirect: it would loop); - anything else (5xx, network, CORS = status 0) → "nicht erreichbar" (no redirect).
- Every API call uses
credentials: "include"(lib/api.ts). Without it the browser sends no cookie and the API answers 401. - Any 401 later (session ended in another app) → toast + login with this page as return target.
GET /admin/me answers 200 for an account without the calendar permission; only apps shows
it. The layout's apps.includes("calendar") check is load-bearing.
Data flow (admin)
components/admin/useEvents.tsloads all five calendars in parallel fromGET /calendar/events/{slug}/json(there is no all-in-one endpoint). Each calendar loads and fails on its own; a per-calendar request counter drops stale responses. Deleted events (the admin feed returns them) are dropped byparseApiEvent.- Filtering, sorting and month grouping are client-side (
lib/filter.ts); nothing is re-fetched when a filter changes. The view state lives in the URL (lib/url-state.ts,useListState):?cal=public,choir&range=past&sort=start_desc&q=probe, defaults omitted. The default sort depends on the range (Vergangen= newest first). - Writes (
components/admin/EventSheet.tsx):- validate (
validateEventForm) → focus the first invalid field, no request; - new →
POST /calendar/events; - edit →
PUT /calendar/events/move/:idif the calendar changed, thenPUT /calendar/events/:idonly if anything else changed (contentChanged); - the sheet closes only after the API answered; a failure keeps the edits and shows the reason.
- After a save the list updates at once (
eventFromPayload), then the affected calendars are re-fetched for server-side fields.
- validate (
- PUT replaces the whole event and defaults missing fields (
status ?? 'PUBLIC',repeatFrequency ?? ''). Every write sends the completeEventPayload- including the quick "Veröffentlichen", which usespayloadFromEvent.
Time
All wall-clock times mean Europe/Berlin, whatever the browser's zone (lib/dates.ts,
@date-fns/tz). Forms hold "YYYY-MM-DD" / "HH:mm" strings; the API gets UTC ISO instants.
- Whole-day events are stored as 00:00 on the first day to 23:59 on the last (inclusive), Berlin. The API's iCal export treats the end date as inclusive and adds a day.
- Repeating events (any
repeatFrequency) count as current and sort by their next occurrence (nextOccurrence); a 29 February birthday falls on 28 February in common years. - Vitest runs with
TZ=America/New_Yorkso Berlin handling can't pass by accident.
Calendars
lib/calendars.ts is the single list: id, slug, German label, colours, default visibility and a
forced recurrence (Birthdays → YEARLY). It mirrors calendarNames in the API's
events.router.ts; a new calendar needs an entry in both.
Public landing page
app/page.tsx fetches GET /calendar/events/public/json on the server (lib/public-calendar.ts,
next: { revalidate: 300 }), anonymously. On any failure the events section is simply left out;
the subscribe links never depend on the API. Links from the database are only rendered if they pass
normaliseUrl (http/https) - old rows were never validated.
Subscription URLs for the internal calendars carry the shared password and come from
GET /calendar/events/subscriptions (editors only), shown in SubscriptionsDialog.
Configuration
| Variable | When | Purpose |
|---|---|---|
NEXT_PUBLIC_API_BASE_URL |
build time (inlined) | API origin for the browser and the subscribe links |
NEXT_PUBLIC_ADMIN_APP_URL |
build time (inlined) | Where signing in happens |
API_BASE_URL |
runtime, optional | Server-side fetch of the landing page's events; may be internal |
lib/env.ts throws on a missing NEXT_PUBLIC_* value, so a misconfigured build fails instead of
shipping localhost URLs.
Tests
npm test - Vitest over the pure logic in lib/ (dates, form rules, filtering, URL state, parsing,
API client with a stubbed fetch, landing page selection). Components are checked in the browser,
not unit-tested (decision Q10).