diff --git a/.browserslistrc b/.browserslistrc deleted file mode 100644 index 4f9ac26..0000000 --- a/.browserslistrc +++ /dev/null @@ -1,16 +0,0 @@ -# This file is used by the build system to adjust CSS and JS output to support the specified browsers below. -# For additional information regarding the format and rule options, please see: -# https://github.com/browserslist/browserslist#queries - -# For the full list of supported browsers by the Angular framework, please see: -# https://angular.io/guide/browser-support - -# You can see what browsers were selected by your queries by running: -# npx browserslist - -last 1 Chrome version -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -Firefox ESR diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..1f306e3 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "nachklang-calendar-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 4200 + } + ] +} diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 3d9a482..0000000 --- a/.editorconfig +++ /dev/null @@ -1,16 +0,0 @@ -# Editor configuration, see https://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = tab -indent_size = 4 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.ts] -quote_type = single - -[*.md] -max_line_length = off -trim_trailing_whitespace = false diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..d77ab4e --- /dev/null +++ b/.env.local.example @@ -0,0 +1,11 @@ +# Browser-side base URL of the API. Every admin call goes here directly and +# carries the shared session cookie (credentials: "include"). +NEXT_PUBLIC_API_BASE_URL=http://localhost:3000 +# Where signed-out editors are sent to sign in. One sign-in covers every +# Nachklang app; this app has no login form of its own. 3002 is the admin app's +# dev port. +NEXT_PUBLIC_ADMIN_APP_URL=http://localhost:3002 +# Optional, server-side only: where the landing page's server render fetches +# the public events from. May be an internal address; falls back to +# NEXT_PUBLIC_API_BASE_URL. Read at request time, not inlined at build. +# API_BASE_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore index ae0d5be..b721bff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,43 +1,42 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# Compiled output -/dist -/tmp -/out-tsc -/bazel-out - -# Node +# dependencies /node_modules -npm-debug.log -yarn-error.log +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions -# IDEs and editors -.idea/ -.vscode/ -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace - -# Visual Studio Code -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -.history/* - -# Miscellaneous -/.angular/cache -.sass-cache/ -/connect.lock +# testing /coverage -/libpeerconnection.log -testem.log -/typings -# System files +# next.js +/.next/ +/out/ + +# production +/build + +# misc .DS_Store -Thumbs.db +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.local.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/CLAUDE.md b/CLAUDE.md index a19a87e..d56f263 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,24 +2,56 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +@AGENTS.md + ## Commands ```bash -npm start # Dev server at http://localhost:4200/ (live reload) -npm run build # Production build -npm run watch # Build with watch mode (development config) -npm test # Run unit tests (Jasmine/Karma in Chrome) +npm run dev # Dev server at http://localhost:4200/ - the port is required, see Authentication +npm run build # Production build (output: standalone; postbuild copies .next/static in) +npm run lint # ESLint (eslint-config-next) +npm test # Vitest, pure logic in lib/ (runs with TZ=America/New_York on purpose) ``` -No linter is configured in this project. +`NEXT_PUBLIC_API_BASE_URL` and `NEXT_PUBLIC_ADMIN_APP_URL` must be set (`.env.local`, from +`.env.local.example`); `lib/env.ts` fails the build without them. + +## Docs + +`docs/` is kept current as part of every change - features in `features.md`, surprises in +`gotchas.md`, API requests in `api-wishlist.md`. + +- [docs/architecture.md](docs/architecture.md) - routes, auth, data flow, time handling +- [docs/features.md](docs/features.md) - what the app does +- [docs/gotchas.md](docs/gotchas.md) - read before assuming how the API, Next or Base UI behaves +- [docs/api-wishlist.md](docs/api-wishlist.md) - API changes this app would like +- [docs/deployment.md](docs/deployment.md) - Plesk build/run, switchover, rollback +- [docs/rewrite-plan.md](docs/rewrite-plan.md) - the Angular → Next rewrite: decisions and progress log ## Architecture -Angular 18 single-page app for managing calendar events for the "Nachklang" organization. Uses Angular Material for UI, RxJS for async data, and reactive forms. No NgRx — state lives in component local variables. +Next.js 16 (App Router) + React 19 + TypeScript + Tailwind 4 + shadcn (Base UI primitives), the same +stack and design system as `nachklang-feedback`. Rewritten from Angular in September 2026. German UI. -**Environments** (`src/environments/`): `apiUrl` and `adminAppUrl`. -- Dev: `http://localhost:3000` (expects backend running locally) / `http://localhost:3002` -- Prod: `https://api.nachklang.art` / `https://admin.nachklang.art` +- `app/page.tsx` - public landing page (server component, `revalidate = 300`). +- `app/admin/` - the Verwaltung: `layout.tsx` is the session gate, `page.tsx` the event list, + `login/page.tsx` a forwarder to the admin app. +- `components/admin/` - list, toolbar, chips, `EventSheet` (create/edit/duplicate/delete) and + `EventForm`, `PublishDialog`, `SubscriptionsDialog`, hooks `useEvents` / `useListState`. +- `lib/` - all logic that isn't rendering, and all of it is unit-tested: `api.ts`, `dates.ts` + (Berlin time), `event-form.ts` (form rules, validation, payloads), `filter.ts`, `url-state.ts`, + `calendars.ts` (the one calendar list), `events.ts` (parsing the API's raw rows). +- `proxy.ts` - cookie-presence gate on `/admin/*`. + +Rules worth knowing before editing (details in `docs/`): + +- **Every wall-clock time is Europe/Berlin**, whatever the browser's zone. Forms hold strings, the API + gets UTC instants. Whole-day events are 00:00-23:59 Berlin, end inclusive. +- **Always send the whole event.** The API's PUT defaults missing fields (`status` → `PUBLIC`, + `repeatFrequency` → `''`). +- **Calendars live in `lib/calendars.ts`** and must match `calendarNames` in the API. +- **After `npx shadcn add`**, point `cn` imports back at `@/lib/utils` and drop the `cn` / + `next-themes` packages it adds (see `docs/gotchas.md`). ## Authentication @@ -29,44 +61,28 @@ feedback and admin apps: accounts live in the admin app, and the session is an h cookie on `.nachklang.art` that the *API's* host sets. Consequences that cannot be designed around: -- `withCredentials: true` is mandatory on every call (`api.service.ts` sets it once). Without - it the browser sends no cookie and the API answers 401. +- `credentials: "include"` is mandatory on every call (`lib/api.ts` sets it once). Without it + the browser sends no cookie and the API answers 401. - This app can never read or verify the session. It calls `GET /admin/me` and believes the - answer; the API's `requireAppAccess('calendar')` is the actual gate. + answer; the API's `requireAppAccess('calendar')` is the actual gate. `/admin/me` answers 200 + for an account without the calendar permission - check `apps`. - 401 and 403 mean different things and must not be collapsed. 401 means "nobody is signed in" and is the only one worth redirecting to the login page - redirecting on 403 produces a - loop where signing in succeeds and lands straight back on the refusal. See `failure` in - `admin.component.ts`. + loop where signing in succeeds and lands straight back on the refusal. See `app/admin/layout.tsx`. - Sign-out ends the session for *all four* apps; there is only one. -The dev server must be reachable at a port the API trusts. `ng serve` defaults to 4200, which +The dev server must be reachable at a port the API trusts. `npm run dev` pins 4200 (`next dev -p 4200`), which is in better-auth's dev `localhostOrigins` and in the admin app's `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS`; another port fails sign-out and the return redirect, not the sign-in. **The public calendar stays anonymous.** `GET /calendar/events/public/json` needs no session -at all, because nachklang.art reads it to show the next upcoming event. +at all, because nachklang.art reads it to show the next upcoming event - and so does this app's +landing page, server-side. -**Routing** (`app.routing.ts`): -- `/` → `LandingpageComponent` -- `/admin` → `AdminComponent` (main calendar management page) -- `**` → `NotfoundComponent` +## Local testing -**Service layer** (`src/app/services/`): -- `api.service.ts` — all HTTP calls to the backend REST API; carries the session cookie via `withCredentials`, holds no credential itself -- `admin-auth.service.ts` — where signing in happens: the admin app's login URL (with a `?redirect=` back here) and sign-out -- `utils.service.ts` — caches the signed-in user's display name for unsaved draft rows. Cosmetic only; the server takes the author from the session - -**Data models** (`src/app/models/`): `Event`, `User`, `Session` - -**Admin page** (`src/app/pages/admin/`) is the core of the app. It owns events state and passes it down to `EventsTableComponent` via `@Input()`. Key features: multi-calendar support (public, members, choir, management, birthdays), event filtering (future/past/all), sorting, and an event-move dialog (`EventMovePopupComponent` via Angular Material Dialog). - -**Calendar-specific behavior:** Birthday calendar auto-sets recurrence to YEARLY. Events have a `status` field (`DRAFT` / `DELETED`). - -**Session lifecycle:** `AdminComponent` calls `me()` on init. 401 redirects to the admin app's -login carrying this URL as the return target; 403 (or a signed-in account without the -`calendar` permission) shows a refusal with Reload/Sign out; anything else shows "cannot be -reached". None of those three redirect, on purpose. - -**Dead since the cutover:** the unrouted `LoginComponent`. `src/app/models/session.ts` is -gone; there is no session type here any more, because this app never handles one. +Use a throwaway database, never shared infrastructure: the API repo's `docker/init` scripts seed a +MariaDB container (podman works) including the `dev@nachklang.art` account; start the API from a +scratch directory with its own `.env`. The exact commands are in the progress log of +`docs/rewrite-plan.md` ("Local test environment"). diff --git a/README.md b/README.md index 31453c0..92a0a47 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,31 @@ -# NachklangCalendar +# Nachklang Calendar -This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 14.2.1. +Event calendar for Nachklang e.V.: a public landing page for subscribing to the calendar, and an +admin area (`/admin`) for editing events in all calendars. -## Development server +Next.js (App Router) + TypeScript + Tailwind + shadcn, the same stack as `nachklang-feedback`. +Rewritten from Angular in September 2026 ([docs/rewrite-plan.md](docs/rewrite-plan.md)). -Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. +Documentation: [architecture](docs/architecture.md) · [features](docs/features.md) · +[gotchas](docs/gotchas.md) · [deployment](docs/deployment.md) · [API wish list](docs/api-wishlist.md) -## Code scaffolding +## Development -Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. +```bash +cp .env.local.example .env.local # then adjust if needed +npm install +npm run dev # http://localhost:4200 +``` -## Build +Port 4200 is not arbitrary: it is the port the API's better-auth origins and the admin app's +redirect allow-list trust. Another port breaks sign-out and the return redirect after sign-in. -Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. +The admin area needs the API (`:3000`) and the admin app (`:3002`) running locally. -## Running unit tests +## Other scripts -Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). - -## Running end-to-end tests - -Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. - -## Further help - -To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. +```bash +npm run build # production build (output: standalone; see docs/deployment.md) +npm run lint +npm test # Vitest, pure logic in lib/ +``` diff --git a/angular.json b/angular.json deleted file mode 100644 index b5ad02f..0000000 --- a/angular.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "newProjectRoot": "projects", - "projects": { - "nachklang-calendar": { - "projectType": "application", - "schematics": {}, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:browser", - "options": { - "outputPath": "dist/nachklang-calendar", - "index": "src/index.html", - "main": "src/main.ts", - "polyfills": "src/polyfills.ts", - "tsConfig": "tsconfig.app.json", - "assets": [ - "src/favicon.ico", - "src/assets" - ], - "styles": [ - "src/styles.css" - ], - "scripts": [] - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "500kb", - "maximumError": "1mb" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "2kb", - "maximumError": "4kb" - } - ], - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.prod.ts" - } - ], - "outputHashing": "all" - }, - "development": { - "buildOptimizer": false, - "optimization": false, - "vendorChunk": true, - "extractLicenses": false, - "sourceMap": true, - "namedChunks": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular-devkit/build-angular:dev-server", - "configurations": { - "production": { - "browserTarget": "nachklang-calendar:build:production" - }, - "development": { - "browserTarget": "nachklang-calendar:build:development" - } - }, - "defaultConfiguration": "development" - }, - "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", - "options": { - "browserTarget": "nachklang-calendar:build" - } - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "main": "src/test.ts", - "polyfills": "src/polyfills.ts", - "tsConfig": "tsconfig.spec.json", - "karmaConfig": "karma.conf.js", - "assets": [ - "src/favicon.ico", - "src/assets" - ], - "styles": [ - "src/styles.css" - ], - "scripts": [] - } - } - } - } - }, - "cli": { - "analytics": false - } -} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..51a4afa --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { LogOutIcon, RefreshCwIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { AdminSessionProvider } from "@/components/admin/AdminSession"; +import { SubscriptionsDialog } from "@/components/admin/SubscriptionsDialog"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Toaster } from "@/components/ui/sonner"; +import { goToAdminLogin, signOut } from "@/lib/admin-auth"; +import { api, ApiError } from "@/lib/api"; +import type { AdminUser } from "@/lib/types"; + +type Failure = "denied" | "unavailable"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + // /admin/login sits under this layout too, and must not be gated: it is the + // forwarder signed-out visitors are sent to. Gating it would hand the admin + // app the forwarder's own URL as the return target - so signing in would + // land back on the forwarder, which forwards again. + const isLoginPage = pathname === "/admin/login"; + const [me, setMe] = useState(null); + const [failure, setFailure] = useState(null); + + // The one call that decides whether the admin area renders. proxy.ts already + // bounced visitors with no cookie at all, but only the API can tell a valid + // session from a stale one, or a colleague without the calendar permission + // from one who has it. + // + // Exactly one status sends the user to the login page: 401. Anything else + // means they may well be signed in already, and bouncing them would sign them + // in, return them here and fail again - an endless loop with nothing on + // screen to explain it, triggered by exactly the partial outage most likely + // just after a deploy. 403 is the permission case; everything else (500, the + // API's 503 when the admin database is down, a network or CORS failure as + // status 0) is a fault worth showing. + useEffect(() => { + if (isLoginPage) return; + api + .me() + .then((user) => { + // Signed in, but not for this app. The API would refuse every write, + // so say so once instead of failing per action. + if (!user.apps.includes("calendar")) { + setFailure("denied"); + return; + } + setMe(user); + }) + .catch((e: unknown) => { + const status = e instanceof ApiError ? e.status : 0; + if (status === 401) { + goToAdminLogin(); + return; + } + setFailure(status === 403 ? "denied" : "unavailable"); + }); + }, [isLoginPage]); + + if (isLoginPage) { + return <>{children}; + } + + if (failure) { + return ; + } + + return ( +
+
+
+ + Nachklang + Kalender + +
+ {me ? ( + {me.fullName} + ) : ( + + )} + {me && } + +
+
+
+ +
+ {me ? {children} : } +
+ +
+ ); +} + +/** What the page looks like while /me is in flight - the list's shape, not a blank page. */ +function LoadingShell() { + return ( +
+ + + {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+ ); +} + +function FailureScreen({ failure }: { failure: Failure }) { + const denied = failure === "denied"; + return ( +
+

{denied ? "Kein Zugriff auf den Kalender" : "Kalender nicht erreichbar"}

+

+ {denied + ? "Dein Konto ist angemeldet, hat aber keine Berechtigung für den Kalender. Eine Administratorin oder ein Administrator kann sie dir in der Verwaltung geben – danach die Seite neu laden." + : "Die Verwaltung ist gerade nicht erreichbar. Bitte lade die Seite in einem Moment neu."} +

+
+ + {denied && ( + + )} +
+
+ ); +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..1bfc892 --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { goToAdminLogin } from "@/lib/admin-auth"; + +/** + * This app has no login form: signing in happens once, on admin.nachklang.art, + * for every Nachklang app. Copied from nachklang-feedback. + * + * The route exists as a forwarder so proxy.ts has a stable path on this origin + * to send signed-out visitors to, rather than building an external URL inside + * the proxy. + */ +export default function AdminLoginPage() { + // Read from the URL directly rather than with useSearchParams(): this page is + // prerendered, and useSearchParams() opts the whole route into client-side + // rendering unless it sits inside a boundary. + const [redirect] = useState(() => + typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("redirect"), + ); + + useEffect(() => { + // Passed straight through; the admin app validates it against its own + // origin allow-list, and a second check here could only drift from that. + goToAdminLogin(redirect || "/admin"); + }, [redirect]); + + return ( +
+

Weiterleitung zur Nachklang-Anmeldung …

+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..67e41fd --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { CircleAlertIcon, PlusIcon } from "lucide-react"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { CalendarChips } from "@/components/admin/CalendarChips"; +import { EventList } from "@/components/admin/EventList"; +import { EventSheet, type SheetMode } from "@/components/admin/EventSheet"; +import { EventsToolbar } from "@/components/admin/EventsToolbar"; +import { PublishDialog } from "@/components/admin/PublishDialog"; +import { useEvents } from "@/components/admin/useEvents"; +import { useListState } from "@/components/admin/useListState"; +import { Button } from "@/components/ui/button"; +import { goToAdminLogin } from "@/lib/admin-auth"; +import { api } from "@/lib/api"; +import { describeApiError, isSessionExpired } from "@/lib/api-errors"; +import { CALENDARS } from "@/lib/calendars"; +import { todayMarkerIndex } from "@/lib/event-display"; +import { payloadFromEvent } from "@/lib/event-form"; +import { filterEvents, matchesQuery, matchesRange, sortEvents } from "@/lib/filter"; +import type { CalendarEvent } from "@/lib/types"; +import { withRange } from "@/lib/url-state"; + +export default function AdminPage() { + // useSearchParams() (in useListState) needs a Suspense boundary in a prerendered page. + return ( + + + + ); +} + +function EventsPage() { + const [state, update] = useListState(); + const { events, loads, reload, refresh, upsertEvent, removeEvent } = useEvents(); + // Fixed per visit: "upcoming" should not reshuffle while someone is looking at it. + const [now] = useState(() => new Date()); + const [publishingId, setPublishingId] = useState(null); + const [confirmPublish, setConfirmPublish] = useState(null); + // The sheet: `mode` outlives `open` by the closing animation; `sheetKey` + // remounts it per opening so no state carries over between events. + const [sheet, setSheet] = useState<{ open: boolean; mode: SheetMode | null; key: number }>({ open: false, mode: null, key: 0 }); + const openSheet = (mode: SheetMode) => setSheet((s) => ({ open: true, mode, key: s.key + 1 })); + + const visible = useMemo( + () => + sortEvents( + filterEvents(events, { calendarIds: state.calendarIds, range: state.range, query: state.query }, now), + state.sort, + now, + ), + [events, state, now], + ); + + // Per-chip counts under the current range and search, whether the chip is on or not. + const counts = useMemo(() => { + const result: Record = {}; + for (const e of events) { + if (matchesRange(e, state.range, now) && matchesQuery(e, state.query)) { + result[e.calendarId] = (result[e.calendarId] ?? 0) + 1; + } + } + return result; + }, [events, state.range, state.query, now]); + + const selectedLoading = state.calendarIds.some((id) => loads[id]?.status === "loading"); + + // "Alle" spans years; the "Heute" marker shows where now is, and opening the + // range scrolls to it. Again after a change of calendars or sort, but not on + // every keystroke in the search, which would make the page jump while typing. + const todayMarker = state.range === "all" ? todayMarkerIndex(visible, state.sort, now) : null; + const scrolledFor = useRef(null); + useEffect(() => { + if (state.range !== "all") { + scrolledFor.current = null; + return; + } + if (selectedLoading || todayMarker === null) return; + const key = `${state.sort}|${state.calendarIds.join(",")}`; + if (scrolledFor.current === key) return; + scrolledFor.current = key; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + document.getElementById("heute")?.scrollIntoView({ block: "center", behavior: reduceMotion ? "auto" : "smooth" }); + }, [state.range, state.sort, state.calendarIds, selectedLoading, todayMarker]); + const failed = CALENDARS.filter((c) => state.calendarIds.includes(c.id) && loads[c.id]?.status === "error"); + + // A new event goes into the calendar being looked at - when that is unambiguous. + const newEvent = () => openSheet({ kind: "new", calendarId: state.calendarIds.length === 1 ? state.calendarIds[0] : 0 }); + + const onSaved = (saved: CalendarEvent, previous: CalendarEvent | null) => { + upsertEvent(saved); + // Then fetch the server's view: uuid, timestamps, resolved author names. + refresh(saved.calendarId); + if (previous && previous.calendarId !== saved.calendarId) refresh(previous.calendarId); + const shown = filterEvents([saved], { calendarIds: state.calendarIds, range: state.range, query: state.query }, now); + if (shown.length === 0) { + toast.info("Der Termin ist mit den aktuellen Filtern ausgeblendet."); + } + }; + + const toggleCalendar = (calendarId: number) => + update({ + calendarIds: state.calendarIds.includes(calendarId) + ? state.calendarIds.filter((id) => id !== calendarId) + : [...state.calendarIds, calendarId], + }); + + // Resends the whole event with only the status changed: PUT replaces every + // field, so a partial body would drop the recurrence and anything else left out. + const publish = async (event: CalendarEvent) => { + setPublishingId(event.eventId); + try { + await api.updateEvent(event.eventId, payloadFromEvent(event, "PUBLIC")); + upsertEvent({ ...event, status: "PUBLIC" }); + setConfirmPublish(null); + toast.success(`„${event.name}“ ist veröffentlicht.`); + refresh(event.calendarId); + } catch (e) { + if (isSessionExpired(e)) { + toast.error("Deine Sitzung ist abgelaufen. Du wirst zur Anmeldung weitergeleitet …"); + goToAdminLogin(); + return; + } + toast.error("Veröffentlichen fehlgeschlagen", { description: describeApiError(e) }); + } finally { + setPublishingId(null); + } + }; + + return ( +
+
+
+

Termine

+ {!selectedLoading && ( + + {visible.length === 1 ? "1 Termin" : `${visible.length} Termine`} + + )} +
+ +
+ + update({ query })} + onRangeChange={(range) => update(withRange(state, range))} + onSortChange={(sort) => update({ sort })} + onReload={() => reload()} + /> + + + + {failed.length > 0 && ( +
+
+ +

+ {failed.map((c) => c.label).join(", ")} {failed.length === 1 ? "konnte" : "konnten"} nicht geladen werden:{" "} + {(loads[failed[0].id] as { message: string }).message} +

+
+ +
+ )} + + openSheet({ kind: "edit", event })} + onPublish={setConfirmPublish} + onClearQuery={() => update({ query: "" })} + /> + + {/* Phones: a thumb-reach button instead of the header one. */} + + + setSheet((s) => ({ ...s, open: false }))} + onClosed={() => setSheet((s) => (s.open ? s : { ...s, mode: null }))} + onSaved={onSaved} + onDeleted={(event) => removeEvent(event.eventId)} + onDuplicate={(event) => openSheet({ kind: "duplicate", event })} + /> + + setConfirmPublish(null)} + /> +
+ ); +} diff --git a/src/favicon.ico b/app/favicon.ico similarity index 100% rename from src/favicon.ico rename to app/favicon.ico diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..50793d2 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,173 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +/* + ---break--- + */ +@custom-variant dark (&:is(.dark *)); + +/* + * Design system: shadcn preset b7ClO5que (style: luma, base color: mist, + * theme: indigo, radius: small). --primary/--accent/--muted-foreground etc. + * below come from that preset — see https://ui.shadcn.com/create?preset=b7ClO5que + * or run `npx shadcn@latest preset decode b7ClO5que`. + * + * A few tokens remain hand-tuned to nachklang.art's actual computed styles + * (see lib/brand.ts and IMPLEMENTATION_PLAN.md §5.7 in nachklang-feedback) rather than coming from + * the preset, since the preset has no equivalent for them: --surface-alt, + * --radius-card/--radius-pill/--radius-small, and --shadow-card below. + * Light-mode only for v1 — the source site has no dark mode either. + */ +:root { + --surface-alt: #f7f7f7; + --radius-pill: 25px; + --radius-small: 4px; + --radius-card: 10px; + /* Matches nachklang.art's actual .card shadow (re-verified live). */ + --shadow-card: 0 2px 15px 0 rgba(0, 0, 0, 0.1); + --background: oklch(1 0 0); + --foreground: oklch(0.148 0.004 228.8); + --card: oklch(1 0 0); + --card-foreground: oklch(0.148 0.004 228.8); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.148 0.004 228.8); + --primary: oklch(0.457 0.24 277.023); + --primary-foreground: oklch(0.962 0.018 272.314); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.963 0.002 197.1); + --muted-foreground: oklch(0.56 0.021 213.5); + --accent: oklch(0.963 0.002 197.1); + --accent-foreground: oklch(0.218 0.008 223.9); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.925 0.005 214.3); + --input: oklch(0.925 0.005 214.3); + --ring: oklch(0.723 0.014 214.4); + --chart-1: oklch(0.872 0.007 219.6); + --chart-2: oklch(0.56 0.021 213.5); + --chart-3: oklch(0.45 0.017 213.2); + --chart-4: oklch(0.378 0.015 216); + --chart-5: oklch(0.275 0.011 216.9); + --radius: 0.45rem; + --sidebar: oklch(0.987 0.002 197.1); + --sidebar-foreground: oklch(0.148 0.004 228.8); + --sidebar-primary: oklch(0.511 0.262 276.966); + --sidebar-primary-foreground: oklch(0.962 0.018 272.314); + --sidebar-accent: oklch(0.963 0.002 197.1); + --sidebar-accent-foreground: oklch(0.218 0.008 223.9); + --sidebar-border: oklch(0.925 0.005 214.3); + --sidebar-ring: oklch(0.723 0.014 214.4); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-accent: var(--accent); + --font-sans: var(--font-sans); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-muted-foreground: var(--muted-foreground); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +body { + font-family: var(--font-sans), Arial, Helvetica, sans-serif; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-heading), var(--font-sans), sans-serif; +} + +/* Plain box-shadow rather than Tailwind's shadow-lg utility, which composes + via chained --tw-shadow custom properties that didn't resolve correctly + in some environments during testing (rendered as no shadow at all). */ +.card-shadow { + box-shadow: var(--shadow-card); +} +/* + ---break--- + */ +.dark { + --background: oklch(0.148 0.004 228.8); + --foreground: oklch(0.987 0.002 197.1); + --card: oklch(0.218 0.008 223.9); + --card-foreground: oklch(0.987 0.002 197.1); + --popover: oklch(0.218 0.008 223.9); + --popover-foreground: oklch(0.987 0.002 197.1); + --primary: oklch(0.398 0.195 277.366); + --primary-foreground: oklch(0.962 0.018 272.314); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.275 0.011 216.9); + --muted-foreground: oklch(0.723 0.014 214.4); + --accent: oklch(0.275 0.011 216.9); + --accent-foreground: oklch(0.987 0.002 197.1); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.56 0.021 213.5); + --chart-1: oklch(0.872 0.007 219.6); + --chart-2: oklch(0.56 0.021 213.5); + --chart-3: oklch(0.45 0.017 213.2); + --chart-4: oklch(0.378 0.015 216); + --chart-5: oklch(0.275 0.011 216.9); + --sidebar: oklch(0.218 0.008 223.9); + --sidebar-foreground: oklch(0.987 0.002 197.1); + --sidebar-primary: oklch(0.585 0.233 277.117); + --sidebar-primary-foreground: oklch(0.962 0.018 272.314); + --sidebar-accent: oklch(0.275 0.011 216.9); + --sidebar-accent-foreground: oklch(0.987 0.002 197.1); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.56 0.021 213.5); +} +/* + ---break--- + */ +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..309a955 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata, Viewport } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { cn } from "@/lib/utils"; + +// Same design system as nachklang-feedback: shadcn preset b7ClO5que (style: +// luma, base: mist, theme: indigo, font: inter). next/font self-hosts the font +// at build time, so there is no runtime Google Fonts call. +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); + +export const metadata: Metadata = { + title: "Nachklang e.V. – Kalender", + description: "Veranstaltungskalender von Nachklang e.V.", +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..7756045 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,195 @@ +import { ArrowUpRightIcon, MapPinIcon } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { CopyLinkCard } from "@/components/landing/CopyLinkCard"; +import { dateBlock, describeWhen } from "@/lib/event-display"; +import { normaliseUrl } from "@/lib/event-form"; +import { API_BASE_URL } from "@/lib/env"; +import { fetchUpcomingPublicEvents, googleCalendarUrl, icalUrl, webcalUrl } from "@/lib/public-calendar"; +import type { CalendarEvent } from "@/lib/types"; + +export const metadata: Metadata = { + title: "Nachklang e.V. – Veranstaltungskalender", + description: "Abonniere den Kalender von Nachklang e.V. und verpasse keine Veranstaltung mehr.", +}; + +// Static, refreshed every five minutes - matches the fetch's own revalidate. +export const revalidate = 300; + +/** + * Where the server fetches from. Optional and read at request time, like in + * nachklang-feedback: it may point at an internal address. The links on the + * page always use the public URL, since visitors' calendar apps fetch them. + */ +const serverApiBaseUrl = (): string => (process.env.API_BASE_URL || API_BASE_URL).replace(/\/+$/, ""); + +// Shared by the three subscribe options, so the two links and the copy button look alike. +// A row on phones; icon above text in the three-column grid, where a row would wrap. +const OPTION = + "flex w-full items-center gap-3 rounded-xl border bg-card p-4 text-left transition-colors sm:flex-col sm:items-start " + + "hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/30 focus-visible:outline-none"; + +/** + * The public page: how to subscribe, and what is coming up. Same look as the + * admin area (design system, light, Inter) - one product, one visual language. + */ +export default async function Home() { + const upcoming = await fetchUpcomingPublicEvents(serverApiBaseUrl(), new Date(), 4); + + return ( +
+
+
+ + Nachklang + Kalender + + + Verwaltung + +
+
+ +
+
+

Veranstaltungskalender

+

+ Abonniere den Kalender von Nachklang e.V. – neue Termine erscheinen dann automatisch in deiner Kalender-App. +

+
+ +
+

+ Abonnieren +

+ +
+ + {upcoming && } +
+ +
+
+ Nachklang e.V. +
+
+
+ ); +} + +function OptionText({ label, sub }: { label: string; sub: string }) { + return ( + + {label} + {sub} + + ); +} + +/** + * The next few public events, in the admin list's style. Hidden entirely when + * the API could not be reached, so the page never shows an error where people + * come to subscribe. + */ +function UpcomingEvents({ events }: { events: CalendarEvent[] }) { + const now = new Date(); + return ( +
+

+ Nächste Termine +

+ {events.length === 0 ? ( +

+ Gerade stehen keine öffentlichen Termine an. +

+ ) : ( +
    + {events.map((event) => { + const block = dateBlock(event.start); + // Links come from the database, and rows older than this app's URL check were never + // validated - only an http(s) URL becomes a link on this public page. + const link = normaliseUrl(event.url); + const href = link.ok && link.value !== "" ? link.value : null; + const content = ( + <> + + {block.weekday} + {block.day} + {block.month} + + + {event.name} + + {describeWhen(event, now)} + {event.location && ( + + + {event.location} + + )} + + + {href && } + + ); + return ( +
  • + {href ? ( + + {content} + + ) : ( +
    {content}
    + )} +
  • + ); + })} +
+ )} +
+ ); +} + +function AppleIcon() { + return ( + + + + + + 31 + + + + ); +} + +function GoogleIcon() { + return ( + + + + + + + + + + + ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..58c2a93 --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-luma", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "mist", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/components/admin/AdminSession.tsx b/components/admin/AdminSession.tsx new file mode 100644 index 0000000..4c52fda --- /dev/null +++ b/components/admin/AdminSession.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { AdminUser } from "@/lib/types"; + +/** + * The signed-in account, for components below the admin layout. The layout + * renders its children only once /me has answered, so inside the admin area + * this is never null. + */ +const AdminSessionContext = createContext(null); + +export const AdminSessionProvider = AdminSessionContext.Provider; + +export const useAdminUser = (): AdminUser => { + const user = useContext(AdminSessionContext); + if (!user) { + throw new Error("useAdminUser() used outside the admin layout"); + } + return user; +}; diff --git a/components/admin/CalendarBadge.tsx b/components/admin/CalendarBadge.tsx new file mode 100644 index 0000000..ec4ae41 --- /dev/null +++ b/components/admin/CalendarBadge.tsx @@ -0,0 +1,19 @@ +import { calendarById } from "@/lib/calendars"; +import { cn } from "@/lib/utils"; + +/** The calendar an event belongs to, in that calendar's colour - never the bare id. */ +export function CalendarBadge({ calendarId, className }: { calendarId: number; className?: string }) { + const calendar = calendarById(calendarId); + return ( + + + {calendar?.label ?? `Kalender ${calendarId}`} + + ); +} diff --git a/components/admin/CalendarChips.tsx b/components/admin/CalendarChips.tsx new file mode 100644 index 0000000..e4f6c2a --- /dev/null +++ b/components/admin/CalendarChips.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { CircleAlertIcon, LoaderCircleIcon } from "lucide-react"; +import type { CalendarLoad } from "@/components/admin/useEvents"; +import { CALENDARS } from "@/lib/calendars"; +import { cn } from "@/lib/utils"; + +/** + * One toggle per calendar. A row that scrolls sideways on phones rather than + * wrapping into three lines of chrome above the list; wraps on wider screens. + */ +export function CalendarChips({ + selected, + counts, + loads, + onToggle, +}: { + selected: readonly number[]; + counts: Record; + loads: Record; + onToggle: (calendarId: number) => void; +}) { + return ( +
+ {CALENDARS.map((calendar) => { + const on = selected.includes(calendar.id); + const load = loads[calendar.id]; + return ( + + ); + })} +
+ ); +} diff --git a/components/admin/EventForm.tsx b/components/admin/EventForm.tsx new file mode 100644 index 0000000..306f8d9 --- /dev/null +++ b/components/admin/EventForm.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { RepeatIcon } from "lucide-react"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { CALENDARS, calendarById } from "@/lib/calendars"; +import { repeatLabel } from "@/lib/event-display"; +import { setCalendar, setStart, type EventForm as EventFormState, type EventFormErrors } from "@/lib/event-form"; +import { cn } from "@/lib/utils"; + +/** Element ids, so the sheet can focus the first field with an error. */ +export const fieldId = (field: keyof EventFormState): string => `event-${field}`; + +const CALENDAR_ITEMS = CALENDARS.map((c) => ({ value: String(c.id), label: c.label })); + +/** + * The event's fields. Presentational: every rule (end follows start, the + * birthday recurrence, validation) lives in lib/event-form.ts, and the sheet + * owns the state. + * + * Inputs are the browser's native date/time pickers, which are the good ones + * on phones. The URL field is plain text with a URL keyboard rather than + * type="url", whose built-in validation would reject "nachklang.art" before + * normaliseUrl() could accept it. + */ +export function EventForm({ + form, + errors, + disabled, + onChange, +}: { + form: EventFormState; + /** Only the errors to show - empty until the first save attempt. */ + errors: EventFormErrors; + disabled: boolean; + onChange: (form: EventFormState) => void; +}) { + const set = (key: K, value: EventFormState[K]) => onChange({ ...form, [key]: value }); + const forcedRepeat = calendarById(form.calendarId)?.forcedRepeatFrequency; + const invalid = (field: keyof EventFormState) => (errors[field] ? true : undefined); + + return ( + + + Kalender + + {errors.calendarId} + + + + Titel + set("name", e.target.value)} + disabled={disabled} + aria-invalid={invalid("name")} + autoComplete="off" + /> + {errors.name} + + + + + Ganztägig + + set("wholeDay", checked)} + disabled={disabled} + /> + + + onChange(setStart(form, date, form.startTime))} + onTime={(time) => onChange(setStart(form, form.startDate, time))} + /> + set("endDate", date)} + onTime={(time) => set("endTime", time)} + /> + + {form.repeatFrequency && ( +

+ + {forcedRepeat + ? `Wiederholt sich ${repeatLabel(form.repeatFrequency)} – das gilt für alle Termine im Kalender „${calendarById(form.calendarId)?.label}“.` + : `Wiederholt sich ${repeatLabel(form.repeatFrequency)}.`} +

+ )} + + + Ort + set("location", e.target.value)} + disabled={disabled} + /> + + + + Link + set("url", e.target.value)} + disabled={disabled} + aria-invalid={invalid("url")} + inputMode="url" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + placeholder="z. B. nachklang.art/konzert" + /> + {errors.url} + + + + Beschreibung +