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,99 @@
|
||||
# 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](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.
|
||||
|
||||
1. `proxy.ts` - a convenience gate on `/admin/*`: no session cookie → `/admin/login?redirect=…`.
|
||||
It only sees the cookie's presence, never its validity.
|
||||
2. `app/admin/login/page.tsx` forwards to `${NEXT_PUBLIC_ADMIN_APP_URL}/login?redirect=<absolute URL>`.
|
||||
3. `app/admin/layout.tsx` calls `GET /admin/me` and decides:
|
||||
- **401** → login (the only status that redirects);
|
||||
- **403**, or 200 without `"calendar"` in `apps` → "Kein Zugriff" (no redirect: it would loop);
|
||||
- anything else (5xx, network, CORS = status 0) → "nicht erreichbar" (no redirect).
|
||||
4. Every API call uses `credentials: "include"` (`lib/api.ts`). Without it the browser sends no
|
||||
cookie and the API answers 401.
|
||||
5. 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.ts` loads all five calendars in parallel from
|
||||
`GET /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 by `parseApiEvent`.
|
||||
- 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/:id` if the calendar changed, then `PUT /calendar/events/:id`
|
||||
only 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.
|
||||
- **PUT replaces the whole event** and defaults missing fields (`status ?? 'PUBLIC'`,
|
||||
`repeatFrequency ?? ''`). Every write sends the complete `EventPayload` - including the quick
|
||||
"Veröffentlichen", which uses `payloadFromEvent`.
|
||||
|
||||
## 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_York` so 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).
|
||||
Reference in new issue
Block a user