Rewrite the calendar frontend in Next.js #22
No files matched your search
@@ -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
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "nachklang-calendar-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 4200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
+36
-37
@@ -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
|
# dependencies
|
||||||
/dist
|
|
||||||
/tmp
|
|
||||||
/out-tsc
|
|
||||||
/bazel-out
|
|
||||||
|
|
||||||
# Node
|
|
||||||
/node_modules
|
/node_modules
|
||||||
npm-debug.log
|
/.pnp
|
||||||
yarn-error.log
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
# IDEs and editors
|
# testing
|
||||||
.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
|
|
||||||
/coverage
|
/coverage
|
||||||
/libpeerconnection.log
|
|
||||||
testem.log
|
|
||||||
/typings
|
|
||||||
|
|
||||||
# System files
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
.DS_Store
|
.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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# 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.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -2,24 +2,56 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
@AGENTS.md
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # Dev server at http://localhost:4200/ (live reload)
|
npm run dev # Dev server at http://localhost:4200/ - the port is required, see Authentication
|
||||||
npm run build # Production build
|
npm run build # Production build (output: standalone; postbuild copies .next/static in)
|
||||||
npm run watch # Build with watch mode (development config)
|
npm run lint # ESLint (eslint-config-next)
|
||||||
npm test # Run unit tests (Jasmine/Karma in Chrome)
|
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
|
## 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`.
|
- `app/page.tsx` - public landing page (server component, `revalidate = 300`).
|
||||||
- Dev: `http://localhost:3000` (expects backend running locally) / `http://localhost:3002`
|
- `app/admin/` - the Verwaltung: `layout.tsx` is the session gate, `page.tsx` the event list,
|
||||||
- Prod: `https://api.nachklang.art` / `https://admin.nachklang.art`
|
`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
|
## 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
|
cookie on `.nachklang.art` that the *API's* host sets. Consequences that cannot be designed
|
||||||
around:
|
around:
|
||||||
|
|
||||||
- `withCredentials: true` is mandatory on every call (`api.service.ts` sets it once). Without
|
- `credentials: "include"` is mandatory on every call (`lib/api.ts` sets it once). Without it
|
||||||
it the browser sends no cookie and the API answers 401.
|
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
|
- 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
|
- 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
|
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
|
loop where signing in succeeds and lands straight back on the refusal. See `app/admin/layout.tsx`.
|
||||||
`admin.component.ts`.
|
|
||||||
- Sign-out ends the session for *all four* apps; there is only one.
|
- 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
|
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,
|
`NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS`; another port fails sign-out and the return redirect,
|
||||||
not the sign-in.
|
not the sign-in.
|
||||||
|
|
||||||
**The public calendar stays anonymous.** `GET /calendar/events/public/json` needs no session
|
**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`):
|
## Local testing
|
||||||
- `/` → `LandingpageComponent`
|
|
||||||
- `/admin` → `AdminComponent` (main calendar management page)
|
|
||||||
- `**` → `NotfoundComponent`
|
|
||||||
|
|
||||||
**Service layer** (`src/app/services/`):
|
Use a throwaway database, never shared infrastructure: the API repo's `docker/init` scripts seed a
|
||||||
- `api.service.ts` — all HTTP calls to the backend REST API; carries the session cookie via `withCredentials`, holds no credential itself
|
MariaDB container (podman works) including the `dev@nachklang.art` account; start the API from a
|
||||||
- `admin-auth.service.ts` — where signing in happens: the admin app's login URL (with a `?redirect=` back here) and sign-out
|
scratch directory with its own `.env`. The exact commands are in the progress log of
|
||||||
- `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
|
`docs/rewrite-plan.md` ("Local test environment").
|
||||||
|
|
||||||
**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.
|
|
||||||
@@ -1,27 +1,31 @@
|
|||||||
# Nachklang Calendar
|
# 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).
|
```bash
|
||||||
|
npm run build # production build (output: standalone; see docs/deployment.md)
|
||||||
## Running end-to-end tests
|
npm run lint
|
||||||
|
npm test # Vitest, pure logic in lib/
|
||||||
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.
|
|
||||||
-104
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<AdminUser | null>(null);
|
||||||
|
const [failure, setFailure] = useState<Failure | null>(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 <FailureScreen failure={failure} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<header className="sticky top-0 z-30 border-b bg-background/90 pt-[env(safe-area-inset-top)] backdrop-blur supports-[backdrop-filter]:bg-background/75">
|
||||||
|
<div className="mx-auto flex h-14 w-full max-w-4xl items-center justify-between gap-3 px-4 sm:px-6">
|
||||||
|
<Link href="/admin" className="flex items-baseline gap-2">
|
||||||
|
<span className="font-semibold tracking-tight">Nachklang</span>
|
||||||
|
<span className="text-sm text-muted-foreground">Kalender</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-1 sm:gap-3">
|
||||||
|
{me ? (
|
||||||
|
<span className="hidden max-w-48 truncate text-sm text-muted-foreground sm:inline">{me.fullName}</span>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="hidden h-4 w-24 sm:block" />
|
||||||
|
)}
|
||||||
|
{me && <SubscriptionsDialog />}
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => void signOut()} aria-label="Abmelden">
|
||||||
|
<LogOutIcon />
|
||||||
|
<span className="hidden sm:inline">Abmelden</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-4xl flex-1 px-4 pt-4 pb-[calc(6rem+env(safe-area-inset-bottom))] sm:px-6 sm:pt-6">
|
||||||
|
{me ? <AdminSessionProvider value={me}>{children}</AdminSessionProvider> : <LoadingShell />}
|
||||||
|
</main>
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the page looks like while /me is in flight - the list's shape, not a blank page. */
|
||||||
|
function LoadingShell() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3" aria-busy="true" aria-label="Wird geladen">
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-20 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FailureScreen({ failure }: { failure: Failure }) {
|
||||||
|
const denied = failure === "denied";
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-dvh max-w-md flex-col items-center justify-center gap-4 px-6 text-center">
|
||||||
|
<h1 className="text-lg font-semibold">{denied ? "Kein Zugriff auf den Kalender" : "Kalender nicht erreichbar"}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||||
|
<RefreshCwIcon />
|
||||||
|
Neu laden
|
||||||
|
</Button>
|
||||||
|
{denied && (
|
||||||
|
<Button variant="ghost" onClick={() => void signOut()}>
|
||||||
|
<LogOutIcon />
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <Suspense> 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 (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">Weiterleitung zur Nachklang-Anmeldung …</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Suspense>
|
||||||
|
<EventsPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number | null>(null);
|
||||||
|
const [confirmPublish, setConfirmPublish] = useState<CalendarEvent | null>(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<number, number> = {};
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Termine</h1>
|
||||||
|
{!selectedLoading && (
|
||||||
|
<span className="text-sm text-muted-foreground tabular-nums">
|
||||||
|
{visible.length === 1 ? "1 Termin" : `${visible.length} Termine`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={newEvent} className="hidden sm:inline-flex">
|
||||||
|
<PlusIcon />
|
||||||
|
Neuer Termin
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventsToolbar
|
||||||
|
query={state.query}
|
||||||
|
range={state.range}
|
||||||
|
sort={state.sort}
|
||||||
|
loading={selectedLoading}
|
||||||
|
onQueryChange={(query) => update({ query })}
|
||||||
|
onRangeChange={(range) => update(withRange(state, range))}
|
||||||
|
onSortChange={(sort) => update({ sort })}
|
||||||
|
onReload={() => reload()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CalendarChips selected={state.calendarIds} counts={counts} loads={loads} onToggle={toggleCalendar} />
|
||||||
|
|
||||||
|
{failed.length > 0 && (
|
||||||
|
<div role="alert" className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm sm:flex-row sm:items-center">
|
||||||
|
<div className="flex flex-1 gap-2">
|
||||||
|
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||||
|
<p>
|
||||||
|
{failed.map((c) => c.label).join(", ")} {failed.length === 1 ? "konnte" : "konnten"} nicht geladen werden:{" "}
|
||||||
|
{(loads[failed[0].id] as { message: string }).message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => failed.forEach((c) => reload(c.id))}>
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EventList
|
||||||
|
events={visible}
|
||||||
|
now={now}
|
||||||
|
sort={state.sort}
|
||||||
|
range={state.range}
|
||||||
|
query={state.query}
|
||||||
|
loading={selectedLoading}
|
||||||
|
noCalendars={state.calendarIds.length === 0}
|
||||||
|
publishingId={publishingId}
|
||||||
|
todayMarker={todayMarker}
|
||||||
|
onOpen={(event) => openSheet({ kind: "edit", event })}
|
||||||
|
onPublish={setConfirmPublish}
|
||||||
|
onClearQuery={() => update({ query: "" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Phones: a thumb-reach button instead of the header one. */}
|
||||||
|
<Button
|
||||||
|
onClick={newEvent}
|
||||||
|
size="icon-lg"
|
||||||
|
aria-label="Neuer Termin"
|
||||||
|
className="fixed right-5 bottom-[calc(1.25rem+env(safe-area-inset-bottom))] z-20 size-14 rounded-full shadow-lg sm:hidden [&_svg:not([class*='size-'])]:size-6"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<EventSheet
|
||||||
|
key={sheet.key}
|
||||||
|
open={sheet.open}
|
||||||
|
mode={sheet.mode}
|
||||||
|
now={now}
|
||||||
|
onClose={() => 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 })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PublishDialog
|
||||||
|
event={confirmPublish}
|
||||||
|
now={now}
|
||||||
|
publishing={publishingId !== null}
|
||||||
|
onConfirm={publish}
|
||||||
|
onCancel={() => setConfirmPublish(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File renamed without changes.
+173
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<html
|
||||||
|
lang="de"
|
||||||
|
className={cn("h-full", "antialiased", "font-sans", inter.variable)}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+195
@@ -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 (
|
||||||
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<header className="border-b pt-[env(safe-area-inset-top)]">
|
||||||
|
<div className="mx-auto flex h-14 w-full max-w-2xl items-center justify-between gap-3 px-4 sm:px-6">
|
||||||
|
<span className="flex items-baseline gap-2">
|
||||||
|
<span className="font-semibold tracking-tight">Nachklang</span>
|
||||||
|
<span className="text-sm text-muted-foreground">Kalender</span>
|
||||||
|
</span>
|
||||||
|
<Link href="/admin" className="text-sm text-muted-foreground transition-colors hover:text-foreground">
|
||||||
|
Verwaltung
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-10 px-4 py-10 sm:px-6 sm:py-14">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Veranstaltungskalender</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Abonniere den Kalender von Nachklang e.V. – neue Termine erscheinen dann automatisch in deiner Kalender-App.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section aria-labelledby="abonnieren" className="flex flex-col gap-3">
|
||||||
|
<h2 id="abonnieren" className="text-sm font-semibold text-muted-foreground">
|
||||||
|
Abonnieren
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<a href={webcalUrl(API_BASE_URL)} className={OPTION}>
|
||||||
|
<AppleIcon />
|
||||||
|
<OptionText label="Apple Kalender" sub="iPhone, iPad, Mac" />
|
||||||
|
</a>
|
||||||
|
<a href={googleCalendarUrl(API_BASE_URL)} target="_blank" rel="noopener noreferrer" className={OPTION}>
|
||||||
|
<GoogleIcon />
|
||||||
|
<OptionText label="Google Kalender" sub="Android, Web" />
|
||||||
|
</a>
|
||||||
|
<CopyLinkCard url={icalUrl(API_BASE_URL)} className={OPTION} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{upcoming && <UpcomingEvents events={upcoming} />}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t">
|
||||||
|
<div className="mx-auto w-full max-w-2xl px-4 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] text-sm text-muted-foreground sm:px-6">
|
||||||
|
Nachklang e.V.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionText({ label, sub }: { label: string; sub: string }) {
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{sub}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<section aria-labelledby="naechste-termine" className="flex flex-col gap-3">
|
||||||
|
<h2 id="naechste-termine" className="text-sm font-semibold text-muted-foreground">
|
||||||
|
Nächste Termine
|
||||||
|
</h2>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Gerade stehen keine öffentlichen Termine an.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y overflow-hidden rounded-xl border bg-card">
|
||||||
|
{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 = (
|
||||||
|
<>
|
||||||
|
<span className="flex w-11 shrink-0 flex-col items-center pt-0.5 text-center leading-tight" aria-hidden>
|
||||||
|
<span className="text-[11px] font-medium text-muted-foreground uppercase">{block.weekday}</span>
|
||||||
|
<span className="text-xl font-semibold tabular-nums">{block.day}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{block.month}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<span className="font-medium">{event.name}</span>
|
||||||
|
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm text-muted-foreground">
|
||||||
|
<span>{describeWhen(event, now)}</span>
|
||||||
|
{event.location && (
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-1">
|
||||||
|
<MapPinIcon aria-hidden className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{event.location}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{href && <ArrowUpRightIcon aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li key={event.eventId}>
|
||||||
|
{href ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex gap-3 px-3 py-3 transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none sm:gap-4 sm:px-4"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-3 px-3 py-3 sm:gap-4 sm:px-4">{content}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppleIcon() {
|
||||||
|
return (
|
||||||
|
<span className="size-10 shrink-0 overflow-hidden rounded-lg ring-1 ring-border">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" aria-hidden className="block size-full">
|
||||||
|
<rect width="48" height="48" fill="white" />
|
||||||
|
<rect width="48" height="14" fill="#FF3B30" />
|
||||||
|
<text x="24" y="41" textAnchor="middle" fontFamily="Helvetica Neue, Helvetica, Arial, sans-serif" fontSize="22" fontWeight="300" fill="#1c1c1e">
|
||||||
|
31
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoogleIcon() {
|
||||||
|
return (
|
||||||
|
<span className="size-10 shrink-0 overflow-hidden rounded-lg ring-1 ring-border">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" aria-hidden className="block size-full">
|
||||||
|
<rect width="48" height="48" fill="white" />
|
||||||
|
<rect width="48" height="14" fill="#1a73e8" />
|
||||||
|
<rect x="8" y="18" width="14" height="12" rx="1.5" fill="#4285f4" />
|
||||||
|
<rect x="26" y="18" width="14" height="12" rx="1.5" fill="#ea4335" />
|
||||||
|
<rect x="8" y="34" width="14" height="11" rx="1.5" fill="#34a853" />
|
||||||
|
<rect x="26" y="34" width="14" height="11" rx="1.5" fill="#fbbc05" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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<AdminUser | null>(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;
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-5 items-center gap-1.5 rounded-full px-2 text-xs font-medium ring-1 ring-inset",
|
||||||
|
calendar?.badgeClass ?? "bg-muted text-muted-foreground ring-border",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden className={cn("size-1.5 rounded-full", calendar?.dotClass ?? "bg-muted-foreground")} />
|
||||||
|
{calendar?.label ?? `Kalender ${calendarId}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<number, number>;
|
||||||
|
loads: Record<number, CalendarLoad>;
|
||||||
|
onToggle: (calendarId: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden"
|
||||||
|
role="group"
|
||||||
|
aria-label="Kalender"
|
||||||
|
>
|
||||||
|
{CALENDARS.map((calendar) => {
|
||||||
|
const on = selected.includes(calendar.id);
|
||||||
|
const load = loads[calendar.id];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={calendar.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => onToggle(calendar.id)}
|
||||||
|
title={load?.status === "error" ? `${calendar.label} konnte nicht geladen werden: ${load.message}` : undefined}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/30",
|
||||||
|
on
|
||||||
|
? "border-foreground/15 bg-foreground/[0.04] font-medium text-foreground"
|
||||||
|
: "border-border text-muted-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("size-2.5 rounded-full", on ? calendar.dotClass : "ring-1 ring-muted-foreground/50 ring-inset")}
|
||||||
|
/>
|
||||||
|
{calendar.label}
|
||||||
|
{load?.status === "loading" ? (
|
||||||
|
<LoaderCircleIcon aria-label="lädt" className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : load?.status === "error" ? (
|
||||||
|
<CircleAlertIcon aria-label="Fehler" className="size-3.5 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">{counts[calendar.id] ?? 0}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = <K extends keyof EventFormState>(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 (
|
||||||
|
<FieldGroup className="gap-5">
|
||||||
|
<Field data-invalid={invalid("calendarId")}>
|
||||||
|
<FieldLabel htmlFor={fieldId("calendarId")}>Kalender</FieldLabel>
|
||||||
|
<Select
|
||||||
|
items={CALENDAR_ITEMS}
|
||||||
|
value={form.calendarId ? String(form.calendarId) : null}
|
||||||
|
onValueChange={(value) => value && onChange(setCalendar(form, Number(value)))}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={fieldId("calendarId")} className="w-full" aria-invalid={invalid("calendarId")}>
|
||||||
|
<SelectValue placeholder="Kalender wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CALENDARS.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>
|
||||||
|
<span aria-hidden className={cn("size-2 rounded-full", c.dotClass)} />
|
||||||
|
{c.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError>{errors.calendarId}</FieldError>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={invalid("name")}>
|
||||||
|
<FieldLabel htmlFor={fieldId("name")}>Titel</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={fieldId("name")}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={invalid("name")}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<FieldError>{errors.name}</FieldError>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldLabel htmlFor={fieldId("wholeDay")} className="font-normal">
|
||||||
|
Ganztägig
|
||||||
|
</FieldLabel>
|
||||||
|
<Switch
|
||||||
|
id={fieldId("wholeDay")}
|
||||||
|
checked={form.wholeDay}
|
||||||
|
onCheckedChange={(checked) => set("wholeDay", checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<DateTimeRow
|
||||||
|
label="Beginn"
|
||||||
|
dateField="startDate"
|
||||||
|
timeField="startTime"
|
||||||
|
date={form.startDate}
|
||||||
|
time={form.startTime}
|
||||||
|
showTime={!form.wholeDay}
|
||||||
|
errors={errors}
|
||||||
|
disabled={disabled}
|
||||||
|
onDate={(date) => onChange(setStart(form, date, form.startTime))}
|
||||||
|
onTime={(time) => onChange(setStart(form, form.startDate, time))}
|
||||||
|
/>
|
||||||
|
<DateTimeRow
|
||||||
|
label="Ende"
|
||||||
|
dateField="endDate"
|
||||||
|
timeField="endTime"
|
||||||
|
date={form.endDate}
|
||||||
|
time={form.endTime}
|
||||||
|
showTime={!form.wholeDay}
|
||||||
|
errors={errors}
|
||||||
|
disabled={disabled}
|
||||||
|
onDate={(date) => set("endDate", date)}
|
||||||
|
onTime={(time) => set("endTime", time)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.repeatFrequency && (
|
||||||
|
<p className="-mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<RepeatIcon aria-hidden className="size-4 shrink-0" />
|
||||||
|
{forcedRepeat
|
||||||
|
? `Wiederholt sich ${repeatLabel(form.repeatFrequency)} – das gilt für alle Termine im Kalender „${calendarById(form.calendarId)?.label}“.`
|
||||||
|
: `Wiederholt sich ${repeatLabel(form.repeatFrequency)}.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={fieldId("location")}>Ort</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={fieldId("location")}
|
||||||
|
value={form.location}
|
||||||
|
onChange={(e) => set("location", e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={invalid("url")}>
|
||||||
|
<FieldLabel htmlFor={fieldId("url")}>Link</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={fieldId("url")}
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<FieldError>{errors.url}</FieldError>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={fieldId("description")}>Beschreibung</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
id={fieldId("description")}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => set("description", e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Status</FieldLabel>
|
||||||
|
{form.status === "PRIVATE" ? (
|
||||||
|
<FieldDescription>Privat – dieser Status wird beim Speichern beibehalten.</FieldDescription>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ToggleGroup
|
||||||
|
value={[form.status]}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const next = value[0];
|
||||||
|
if (next === "DRAFT" || next === "PUBLIC") set("status", next);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
spacing={0}
|
||||||
|
disabled={disabled}
|
||||||
|
className="grid w-full grid-cols-2"
|
||||||
|
aria-label="Status"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="DRAFT" className="aria-pressed:bg-foreground/[0.06] aria-pressed:font-semibold">
|
||||||
|
Entwurf
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="PUBLIC" className="aria-pressed:bg-foreground/[0.06] aria-pressed:font-semibold">
|
||||||
|
Veröffentlicht
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
<FieldDescription>
|
||||||
|
{form.status === "DRAFT"
|
||||||
|
? "Entwürfe sieht nur, wer hier angemeldet ist – nicht in Abos und nicht auf der Website."
|
||||||
|
: "Veröffentlichte Termine erscheinen in den Kalender-Abos."}
|
||||||
|
</FieldDescription>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateTimeRow({
|
||||||
|
label,
|
||||||
|
dateField,
|
||||||
|
timeField,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
showTime,
|
||||||
|
errors,
|
||||||
|
disabled,
|
||||||
|
onDate,
|
||||||
|
onTime,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
dateField: "startDate" | "endDate";
|
||||||
|
timeField: "startTime" | "endTime";
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
showTime: boolean;
|
||||||
|
errors: EventFormErrors;
|
||||||
|
disabled: boolean;
|
||||||
|
onDate: (date: string) => void;
|
||||||
|
onTime: (time: string) => void;
|
||||||
|
}) {
|
||||||
|
const error = errors[dateField] ?? (showTime ? errors[timeField] : undefined);
|
||||||
|
return (
|
||||||
|
<Field data-invalid={error ? true : undefined}>
|
||||||
|
<FieldLabel htmlFor={fieldId(dateField)}>{label}</FieldLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id={fieldId(dateField)}
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => onDate(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={errors[dateField] ? true : undefined}
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
/>
|
||||||
|
{showTime && (
|
||||||
|
<Input
|
||||||
|
id={fieldId(timeField)}
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => onTime(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={errors[timeField] ? true : undefined}
|
||||||
|
aria-label={`${label} Uhrzeit`}
|
||||||
|
className="w-28 shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FieldError>{error}</FieldError>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LoaderCircleIcon, MapPinIcon, RepeatIcon, SendIcon } from "lucide-react";
|
||||||
|
import { CalendarBadge } from "@/components/admin/CalendarBadge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { dateBlock, describeWhen, isPast, occurrence, repeatLabel } from "@/lib/event-display";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event in the list. The whole row opens the event (a stretched button on
|
||||||
|
* the title, so the row's other button - "Veröffentlichen" - stays a separate,
|
||||||
|
* valid control rather than a button inside a button).
|
||||||
|
*/
|
||||||
|
export function EventListItem({
|
||||||
|
event,
|
||||||
|
now,
|
||||||
|
publishing,
|
||||||
|
onOpen,
|
||||||
|
onPublish,
|
||||||
|
}: {
|
||||||
|
event: CalendarEvent;
|
||||||
|
now: Date;
|
||||||
|
publishing: boolean;
|
||||||
|
onOpen?: (event: CalendarEvent) => void;
|
||||||
|
onPublish: (event: CalendarEvent) => void;
|
||||||
|
}) {
|
||||||
|
const { start } = occurrence(event, now);
|
||||||
|
const block = dateBlock(start);
|
||||||
|
const repeat = repeatLabel(event.repeatFrequency);
|
||||||
|
const title = event.name || "(ohne Titel)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
"relative flex gap-3 px-3 py-3 transition-colors sm:gap-4 sm:px-4",
|
||||||
|
onOpen && "hover:bg-muted/50 has-[button[data-open]:focus-visible]:bg-muted/50",
|
||||||
|
isPast(event, now) && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex w-11 shrink-0 flex-col items-center pt-0.5 text-center leading-tight" aria-hidden>
|
||||||
|
<span className="text-[11px] font-medium text-muted-foreground uppercase">{block.weekday}</span>
|
||||||
|
<span className="text-xl font-semibold tabular-nums">{block.day}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{block.month}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
{onOpen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-open
|
||||||
|
onClick={() => onOpen(event)}
|
||||||
|
className="text-left font-medium outline-none after:absolute after:inset-0 after:content-['']"
|
||||||
|
>
|
||||||
|
<span className="line-clamp-2">{title}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="line-clamp-2 font-medium">{title}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm text-muted-foreground">
|
||||||
|
<span>{describeWhen(event, now)}</span>
|
||||||
|
{event.location && (
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-1">
|
||||||
|
<MapPinIcon aria-hidden className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{event.location}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
<CalendarBadge calendarId={event.calendarId} />
|
||||||
|
{event.status === "DRAFT" && (
|
||||||
|
<span className="inline-flex h-5 items-center rounded-full border border-dashed border-amber-400 px-2 text-xs font-medium text-amber-800">
|
||||||
|
Entwurf
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{event.status === "PRIVATE" && (
|
||||||
|
<span className="inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Privat
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{repeat && (
|
||||||
|
<span className="inline-flex h-5 items-center gap-1 rounded-full bg-muted px-2 text-xs text-muted-foreground">
|
||||||
|
<RepeatIcon aria-hidden className="size-3" />
|
||||||
|
{repeat}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{event.status === "DRAFT" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="relative z-10 ml-auto"
|
||||||
|
disabled={publishing}
|
||||||
|
onClick={() => onPublish(event)}
|
||||||
|
>
|
||||||
|
{publishing ? <LoaderCircleIcon className="animate-spin" /> : <SendIcon />}
|
||||||
|
Veröffentlichen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CircleAlertIcon, CopyIcon, LoaderCircleIcon, Trash2Icon, XIcon } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAdminUser } from "@/components/admin/AdminSession";
|
||||||
|
import { EventForm, fieldId } from "@/components/admin/EventForm";
|
||||||
|
import { useMediaQuery } from "@/components/admin/useMediaQuery";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { goToAdminLogin } from "@/lib/admin-auth";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
|
||||||
|
import { calendarById } from "@/lib/calendars";
|
||||||
|
import { formatDateTime } from "@/lib/dates";
|
||||||
|
import {
|
||||||
|
contentChanged,
|
||||||
|
duplicateForm,
|
||||||
|
eventFromPayload,
|
||||||
|
formFromEvent,
|
||||||
|
isFormDirty,
|
||||||
|
newEventForm,
|
||||||
|
toPayload,
|
||||||
|
validateEventForm,
|
||||||
|
type EventForm as EventFormState,
|
||||||
|
type EventFormField,
|
||||||
|
} from "@/lib/event-form";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
export type SheetMode =
|
||||||
|
| { kind: "new"; calendarId: number }
|
||||||
|
| { kind: "edit"; event: CalendarEvent }
|
||||||
|
| { kind: "duplicate"; event: CalendarEvent };
|
||||||
|
|
||||||
|
const TITLES: Record<SheetMode["kind"], string> = {
|
||||||
|
new: "Neuer Termin",
|
||||||
|
edit: "Termin bearbeiten",
|
||||||
|
duplicate: "Termin duplizieren",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top to bottom, so focus lands on the first problem the user would see.
|
||||||
|
const FIELD_ORDER: EventFormField[] = ["calendarId", "name", "startDate", "startTime", "endDate", "endTime", "url"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create, edit or duplicate one event. A bottom sheet on phones, a side panel
|
||||||
|
* from `sm` up.
|
||||||
|
*
|
||||||
|
* Nothing closes before the API has answered - the Angular row snapped shut
|
||||||
|
* the moment Save was clicked, so a failed save looked saved. On a failure the
|
||||||
|
* sheet stays open with the edits and says what went wrong; a 401 is the one
|
||||||
|
* failure that leaves, to the login page.
|
||||||
|
*
|
||||||
|
* The parent gives it a fresh `key` per opening, so state never leaks from one
|
||||||
|
* event into the next.
|
||||||
|
*/
|
||||||
|
export function EventSheet({
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
now,
|
||||||
|
onClose,
|
||||||
|
onClosed,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
onDuplicate,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
mode: SheetMode | null;
|
||||||
|
now: Date;
|
||||||
|
/** Asked to close; the sheet has already dealt with unsaved changes. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** The closing animation finished - safe to drop `mode`. */
|
||||||
|
onClosed: () => void;
|
||||||
|
onSaved: (event: CalendarEvent, previous: CalendarEvent | null) => void;
|
||||||
|
onDeleted: (event: CalendarEvent) => void;
|
||||||
|
onDuplicate: (event: CalendarEvent) => void;
|
||||||
|
}) {
|
||||||
|
const user = useAdminUser();
|
||||||
|
const desktop = useMediaQuery("(min-width: 640px)");
|
||||||
|
|
||||||
|
// What "unchanged" means. Moves forward after a partial save (moved, but the
|
||||||
|
// update failed), so a retry doesn't try to move again.
|
||||||
|
const [original, setOriginal] = useState<CalendarEvent | null>(mode?.kind === "edit" ? mode.event : null);
|
||||||
|
const [initial, setInitial] = useState<EventFormState | null>(() => (mode ? initialForm(mode, now) : null));
|
||||||
|
const [form, setForm] = useState<EventFormState | null>(initial);
|
||||||
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const errors = useMemo(() => (form ? validateEventForm(form) : {}), [form]);
|
||||||
|
const dirty = form !== null && initial !== null && isFormDirty(initial, form);
|
||||||
|
const busy = saving || deleting;
|
||||||
|
|
||||||
|
// Leaving the page with unsaved edits - reload, closing the tab - asks too.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !dirty) return;
|
||||||
|
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||||
|
window.addEventListener("beforeunload", warn);
|
||||||
|
return () => window.removeEventListener("beforeunload", warn);
|
||||||
|
}, [open, dirty]);
|
||||||
|
|
||||||
|
const requestClose = () => {
|
||||||
|
if (busy) return;
|
||||||
|
if (dirty) {
|
||||||
|
setConfirmDiscard(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const leaveForLogin = () => {
|
||||||
|
toast.error("Deine Sitzung ist abgelaufen. Du wirst zur Anmeldung weitergeleitet …");
|
||||||
|
goToAdminLogin();
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!form || !initial || !mode) return;
|
||||||
|
setShowErrors(true);
|
||||||
|
setSaveError(null);
|
||||||
|
const firstError = FIELD_ORDER.find((field) => errors[field]);
|
||||||
|
if (firstError) {
|
||||||
|
document.getElementById(fieldId(firstError))?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = toPayload(form);
|
||||||
|
setSaving(true);
|
||||||
|
let moved = false;
|
||||||
|
try {
|
||||||
|
if (!original) {
|
||||||
|
const eventId = await api.createEvent(payload);
|
||||||
|
onSaved(eventFromPayload(eventId, payload, user.fullName, new Date()), null);
|
||||||
|
toast.success(`„${payload.name}“ ist angelegt.`);
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar first, then content: a move only rewrites the calendar, so if
|
||||||
|
// the second call fails the event is at least where it was sent, and the
|
||||||
|
// message can say exactly that.
|
||||||
|
if (payload.calendarId !== original.calendarId) {
|
||||||
|
await api.moveEvent(original.eventId, payload);
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
if (contentChanged(initial, form)) {
|
||||||
|
await api.updateEvent(original.eventId, payload);
|
||||||
|
}
|
||||||
|
onSaved(eventFromPayload(original.eventId, payload, user.fullName, new Date(), original), original);
|
||||||
|
toast.success(`„${payload.name}“ ist gespeichert.`);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
if (isSessionExpired(e)) {
|
||||||
|
leaveForLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (moved && original) {
|
||||||
|
// Half done: record the move, keep the other edits on screen.
|
||||||
|
const movedEvent = { ...original, calendarId: payload.calendarId };
|
||||||
|
onSaved(movedEvent, original);
|
||||||
|
setOriginal(movedEvent);
|
||||||
|
setInitial({ ...initial, calendarId: payload.calendarId });
|
||||||
|
setSaveError(
|
||||||
|
`Der Termin wurde nach „${calendarById(payload.calendarId)?.label}“ verschoben, die übrigen Änderungen aber nicht gespeichert: ${describeApiError(e)}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSaveError(describeApiError(e));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!original) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.deleteEvent(original.eventId);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
onDeleted(original);
|
||||||
|
toast.success(`„${original.name}“ ist gelöscht.`);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
if (isSessionExpired(e)) {
|
||||||
|
leaveForLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmDelete(false);
|
||||||
|
setSaveError(`Löschen fehlgeschlagen: ${describeApiError(e)}`);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) requestClose();
|
||||||
|
}}
|
||||||
|
onOpenChangeComplete={(isOpen) => {
|
||||||
|
if (!isOpen) onClosed();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SheetContent
|
||||||
|
side={desktop ? "right" : "bottom"}
|
||||||
|
showCloseButton={false}
|
||||||
|
className="gap-0 data-[side=bottom]:max-h-[92dvh] data-[side=bottom]:rounded-t-2xl data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
|
||||||
|
>
|
||||||
|
{mode && form && (
|
||||||
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<SheetHeader className="flex-row items-start justify-between gap-3 border-b p-4">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<SheetTitle>{TITLES[mode.kind]}</SheetTitle>
|
||||||
|
<SheetDescription className={mode.kind === "edit" ? "line-clamp-1" : undefined}>
|
||||||
|
{mode.kind === "edit"
|
||||||
|
? mode.event.name
|
||||||
|
: mode.kind === "duplicate"
|
||||||
|
? `Kopie von „${mode.event.name}“ – wird als neuer Entwurf angelegt.`
|
||||||
|
: "Wird als Entwurf angelegt, wenn du den Status nicht änderst."}
|
||||||
|
</SheetDescription>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" onClick={requestClose} aria-label="Schließen" disabled={busy}>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5">
|
||||||
|
<EventForm form={form} errors={showErrors ? errors : {}} disabled={busy} onChange={setForm} />
|
||||||
|
{original && <Metadata event={original} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-t p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
|
||||||
|
{saveError && (
|
||||||
|
<div role="alert" className="flex gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<p>{saveError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Labels follow the footer's own width (container query), not the window's: the
|
||||||
|
side panel is capped, so a wide window says nothing about the room in here. */}
|
||||||
|
<div className="@container flex items-center gap-2">
|
||||||
|
{original && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Löschen"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
<span className="hidden @md:inline">Löschen</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDuplicate(original)}
|
||||||
|
// Duplicating would silently drop the edits made so far.
|
||||||
|
disabled={busy || dirty}
|
||||||
|
title={dirty ? "Erst speichern oder verwerfen, dann duplizieren" : "Duplizieren"}
|
||||||
|
aria-label="Duplizieren"
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
<span className="hidden @md:inline">Duplizieren</span>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={requestClose} disabled={busy}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={busy}>
|
||||||
|
{saving && <LoaderCircleIcon className="animate-spin" />}
|
||||||
|
{original ? "Speichern" : "Anlegen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<AlertDialog open={confirmDiscard} onOpenChange={setConfirmDiscard}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Änderungen verwerfen?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Was du hier geändert hast, ist noch nicht gespeichert.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Weiter bearbeiten</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmDiscard(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verwerfen
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={confirmDelete} onOpenChange={(next) => !deleting && setConfirmDelete(next)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>„{original?.name}“ löschen?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Der Termin verschwindet aus dem Kalender und aus allen Abos. Das lässt sich hier nicht rückgängig machen.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleting}>Abbrechen</AlertDialogCancel>
|
||||||
|
<Button variant="destructive" onClick={() => void remove()} disabled={deleting}>
|
||||||
|
{deleting ? <LoaderCircleIcon className="animate-spin" /> : <Trash2Icon />}
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialForm(mode: SheetMode, now: Date): EventFormState {
|
||||||
|
switch (mode.kind) {
|
||||||
|
case "new":
|
||||||
|
return newEventForm(now, mode.calendarId);
|
||||||
|
case "edit":
|
||||||
|
return formFromEvent(mode.event);
|
||||||
|
case "duplicate":
|
||||||
|
return duplicateForm(mode.event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who made and last changed the event. Legacy rows can lack either name. */
|
||||||
|
function Metadata({ event }: { event: CalendarEvent }) {
|
||||||
|
const line = (label: string, who: string, when: Date | null) =>
|
||||||
|
when || who ? (
|
||||||
|
<p>
|
||||||
|
{label}
|
||||||
|
{who && ` von ${who}`}
|
||||||
|
{when && ` · ${formatDateTime(when)}`}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
return (
|
||||||
|
<div className="mt-6 flex flex-col gap-0.5 border-t pt-4 text-xs text-muted-foreground">
|
||||||
|
{line("Erstellt", event.createdBy, event.created)}
|
||||||
|
{line("Zuletzt geändert", event.lastModifiedBy, event.lastModified)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowDownUpIcon, RefreshCwIcon, SearchIcon, XIcon } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import type { Range, Sort } from "@/lib/filter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const RANGE_LABELS: Record<Range, string> = { future: "Kommend", past: "Vergangen", all: "Alle" };
|
||||||
|
const SORT_LABELS: Record<Sort, string> = {
|
||||||
|
start_asc: "Beginn – früheste zuerst",
|
||||||
|
start_desc: "Beginn – späteste zuerst",
|
||||||
|
created_asc: "Erstellt – älteste zuerst",
|
||||||
|
created_desc: "Erstellt – neueste zuerst",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search, sort, reload and the time range. The search box keeps its own text
|
||||||
|
* and writes it to the URL after a short pause, so typing doesn't replace the
|
||||||
|
* URL on every keystroke.
|
||||||
|
*/
|
||||||
|
export function EventsToolbar({
|
||||||
|
query,
|
||||||
|
range,
|
||||||
|
sort,
|
||||||
|
loading,
|
||||||
|
onQueryChange,
|
||||||
|
onRangeChange,
|
||||||
|
onSortChange,
|
||||||
|
onReload,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
range: Range;
|
||||||
|
sort: Sort;
|
||||||
|
loading: boolean;
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
|
onRangeChange: (range: Range) => void;
|
||||||
|
onSortChange: (sort: Sort) => void;
|
||||||
|
onReload: () => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState(query);
|
||||||
|
// Take over a query that changed from outside (the empty state's "Suche
|
||||||
|
// leeren", the back button) - but not our own write coming back through the
|
||||||
|
// URL, which would drop whatever was typed while it travelled.
|
||||||
|
const [seenQuery, setSeenQuery] = useState(query);
|
||||||
|
const [emitted, setEmitted] = useState(query);
|
||||||
|
if (query !== seenQuery) {
|
||||||
|
setSeenQuery(query);
|
||||||
|
if (query !== emitted) {
|
||||||
|
setText(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onQueryChangeRef = useRef(onQueryChange);
|
||||||
|
useEffect(() => {
|
||||||
|
onQueryChangeRef.current = onQueryChange;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (text === query) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setEmitted(text);
|
||||||
|
onQueryChangeRef.current(text);
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [text, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<SearchIcon aria-hidden className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Titel oder Ort suchen"
|
||||||
|
aria-label="Termine durchsuchen"
|
||||||
|
className="pr-9 pl-9 [&::-webkit-search-cancel-button]:hidden"
|
||||||
|
/>
|
||||||
|
{text !== "" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setText("");
|
||||||
|
onQueryChange("");
|
||||||
|
}}
|
||||||
|
aria-label="Suche leeren"
|
||||||
|
className="absolute top-1/2 right-2 flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="Sortierung" />}>
|
||||||
|
<ArrowDownUpIcon />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-60">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>Sortieren nach</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup value={sort} onValueChange={(value) => onSortChange(value as Sort)}>
|
||||||
|
{(Object.keys(SORT_LABELS) as Sort[]).map((key) => (
|
||||||
|
// Base UI keeps a menu open after a radio pick by default; one choice is all there is here.
|
||||||
|
<DropdownMenuRadioItem key={key} value={key} closeOnClick>
|
||||||
|
{SORT_LABELS[key]}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button variant="outline" size="icon" onClick={onReload} aria-label="Neu laden" disabled={loading}>
|
||||||
|
<RefreshCwIcon className={cn(loading && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToggleGroup
|
||||||
|
value={[range]}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
// A segmented control: clicking the pressed item must not leave nothing selected.
|
||||||
|
const next = value[0] as Range | undefined;
|
||||||
|
if (next) onRangeChange(next);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
spacing={0}
|
||||||
|
className="grid w-full grid-cols-3 sm:inline-grid sm:w-auto"
|
||||||
|
aria-label="Zeitraum"
|
||||||
|
>
|
||||||
|
{(Object.keys(RANGE_LABELS) as Range[]).map((key) => (
|
||||||
|
<ToggleGroupItem key={key} value={key} className="aria-pressed:bg-foreground/[0.06] aria-pressed:font-semibold">
|
||||||
|
{RANGE_LABELS[key]}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LoaderCircleIcon, SendIcon } from "lucide-react";
|
||||||
|
import { CalendarBadge } from "@/components/admin/CalendarBadge";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { calendarById } from "@/lib/calendars";
|
||||||
|
import { formatDay } from "@/lib/dates";
|
||||||
|
import { describeWhen, occurrence } from "@/lib/event-display";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Really publish?" - publishing puts the event into every subscriber's
|
||||||
|
* calendar app, and for the public calendar onto nachklang.art, which is not
|
||||||
|
* something to do by a stray tap on a phone.
|
||||||
|
*
|
||||||
|
* Stays open with a spinner until the API has answered, and stays open on a
|
||||||
|
* failure (the page shows the error as a toast), so a retry is one tap.
|
||||||
|
*/
|
||||||
|
export function PublishDialog({
|
||||||
|
event,
|
||||||
|
now,
|
||||||
|
publishing,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
event: CalendarEvent | null;
|
||||||
|
now: Date;
|
||||||
|
publishing: boolean;
|
||||||
|
onConfirm: (event: CalendarEvent) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const calendar = event ? calendarById(event.calendarId) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog
|
||||||
|
open={event !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !publishing) onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
{event && (
|
||||||
|
<>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>„{event.name}“ veröffentlichen?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{calendar?.slug === "public"
|
||||||
|
? "Der Termin wird danach öffentlich angezeigt – auf nachklang.art und bei allen, die den Kalender abonniert haben."
|
||||||
|
: `Der Termin erscheint danach bei allen, die den Kalender „${calendar?.label ?? event.calendarId}“ abonniert haben.`}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="flex flex-col gap-1.5 rounded-lg bg-muted/60 px-3 py-2.5 text-sm">
|
||||||
|
<span>
|
||||||
|
{formatDay(occurrence(event, now).start)} · {describeWhen(event, now)}
|
||||||
|
</span>
|
||||||
|
<CalendarBadge calendarId={event.calendarId} className="self-start" />
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={publishing}>Abbrechen</AlertDialogCancel>
|
||||||
|
<Button onClick={() => onConfirm(event)} disabled={publishing}>
|
||||||
|
{publishing ? <LoaderCircleIcon className="animate-spin" /> : <SendIcon />}
|
||||||
|
Veröffentlichen
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CalendarPlusIcon, CheckIcon, CircleAlertIcon, CopyIcon, RefreshCwIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { goToAdminLogin } from "@/lib/admin-auth";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
|
||||||
|
import { CALENDARS, type CalendarSlug } from "@/lib/calendars";
|
||||||
|
import { toGoogleCalendar, toWebcal } from "@/lib/public-calendar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Subscription = { calendar: CalendarSlug; icalUrl: string };
|
||||||
|
type Load = { status: "idle" } | { status: "loading" } | { status: "ready"; subscriptions: Subscription[] } | { status: "error"; message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription links for every calendar, for the editor's own phone or to
|
||||||
|
* pass on to members. The URLs of the internal calendars carry their shared
|
||||||
|
* password, which is why they come from the API (GET
|
||||||
|
* /calendar/events/subscriptions, editors only) rather than being built here -
|
||||||
|
* the browser has no other way to know the passwords.
|
||||||
|
*
|
||||||
|
* Fetched each time the dialog opens, so a rotated password shows up at once.
|
||||||
|
*/
|
||||||
|
export function SubscriptionsDialog() {
|
||||||
|
const [load, setLoad] = useState<Load>({ status: "idle" });
|
||||||
|
const [copied, setCopied] = useState<CalendarSlug | null>(null);
|
||||||
|
|
||||||
|
const fetchLinks = async () => {
|
||||||
|
setLoad({ status: "loading" });
|
||||||
|
try {
|
||||||
|
setLoad({ status: "ready", subscriptions: await api.getSubscriptions() });
|
||||||
|
} catch (e) {
|
||||||
|
if (isSessionExpired(e)) {
|
||||||
|
goToAdminLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoad({ status: "error", message: describeApiError(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async (subscription: Subscription) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(subscription.icalUrl);
|
||||||
|
setCopied(subscription.calendar);
|
||||||
|
setTimeout(() => setCopied((current) => (current === subscription.calendar ? null : current)), 2000);
|
||||||
|
} catch {
|
||||||
|
// No clipboard access: the URL is selectable in the dialog instead.
|
||||||
|
setCopied(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) void fetchLinks();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger render={<Button variant="ghost" size="sm" aria-label="Abonnieren" title="Kalender abonnieren" />}>
|
||||||
|
<CalendarPlusIcon />
|
||||||
|
<span className="hidden sm:inline">Abonnieren</span>
|
||||||
|
</DialogTrigger>
|
||||||
|
{/* minmax(0,1fr): an implicit grid column grows to its longest unbreakable content - here a
|
||||||
|
full URL - so `truncate` on the URL never kicked in and the dialog's content overflowed. */}
|
||||||
|
<DialogContent className="max-h-[90dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Kalender abonnieren</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Mit diesen Links erscheinen die Termine automatisch in einer Kalender-App. Die Links der internen Kalender
|
||||||
|
enthalten das jeweilige Kalender-Passwort – bitte nur an Mitglieder weitergeben.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{load.status === "loading" || load.status === "idle" ? (
|
||||||
|
<div className="flex flex-col gap-2" aria-busy="true" aria-label="Links werden geladen">
|
||||||
|
{CALENDARS.map((c) => (
|
||||||
|
<Skeleton key={c.id} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : load.status === "error" ? (
|
||||||
|
<div role="alert" className="flex flex-col items-start gap-3 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<p className="flex gap-2">
|
||||||
|
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||||
|
Die Links konnten nicht geladen werden: {load.message}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void fetchLinks()}>
|
||||||
|
<RefreshCwIcon />
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-xl border">
|
||||||
|
{CALENDARS.map((calendar) => {
|
||||||
|
const subscription = load.subscriptions.find((s) => s.calendar === calendar.slug);
|
||||||
|
return (
|
||||||
|
<li key={calendar.id} className="flex flex-col gap-2 p-3">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<span aria-hidden className={cn("size-2.5 rounded-full", calendar.dotClass)} />
|
||||||
|
{calendar.label}
|
||||||
|
</span>
|
||||||
|
{subscription ? (
|
||||||
|
<>
|
||||||
|
<code className="block truncate rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground select-all">
|
||||||
|
{subscription.icalUrl}
|
||||||
|
</code>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="xs" render={<a href={toWebcal(subscription.icalUrl)} />}>
|
||||||
|
Apple Kalender
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
render={<a href={toGoogleCalendar(subscription.icalUrl)} target="_blank" rel="noopener noreferrer" />}
|
||||||
|
>
|
||||||
|
Google Kalender
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="xs" onClick={() => void copy(subscription)}>
|
||||||
|
{copied === calendar.slug ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
{copied === calendar.slug ? "Kopiert" : "Link kopieren"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Für diesen Kalender ist auf dem Server kein Passwort eingerichtet, daher gibt es keinen Abo-Link.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Chor und Geburtstage teilen sich ein Passwort: Wird es geändert, müssen beide neu abonniert werden.
|
||||||
|
</p>
|
||||||
|
<DialogFooter showCloseButton />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { goToAdminLogin } from "@/lib/admin-auth";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
|
||||||
|
import { CALENDARS, calendarById } from "@/lib/calendars";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
export type CalendarLoad = { status: "loading" } | { status: "ready" } | { status: "error"; message: string };
|
||||||
|
|
||||||
|
const initialLoads = (): Record<number, CalendarLoad> =>
|
||||||
|
Object.fromEntries(CALENDARS.map((c) => [c.id, { status: "loading" } as CalendarLoad]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every calendar's events, loaded in parallel - there is no endpoint that
|
||||||
|
* returns them all at once (docs/api-wishlist.md).
|
||||||
|
*
|
||||||
|
* Each calendar loads and fails on its own: one calendar the API cannot serve
|
||||||
|
* shows an error on its chip instead of blanking the whole list. The admin feed
|
||||||
|
* returns exactly the calendar's own events (it ignores `includes_calendars`),
|
||||||
|
* so a calendar's slice of the list can be replaced by calendarId.
|
||||||
|
*
|
||||||
|
* A 401 on any of them means the session is gone, and goes to the login page.
|
||||||
|
*/
|
||||||
|
export function useEvents() {
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
|
const [loads, setLoads] = useState<Record<number, CalendarLoad>>(initialLoads);
|
||||||
|
// Per-calendar request counter, so a slow response cannot overwrite a newer one.
|
||||||
|
const generation = useRef<Record<number, number>>({});
|
||||||
|
|
||||||
|
const fetchCalendar = useCallback(async (calendarId: number) => {
|
||||||
|
const calendar = calendarById(calendarId);
|
||||||
|
if (!calendar) return;
|
||||||
|
const mine = (generation.current[calendarId] = (generation.current[calendarId] ?? 0) + 1);
|
||||||
|
try {
|
||||||
|
const loaded = await api.getEvents(calendar.slug);
|
||||||
|
if (generation.current[calendarId] !== mine) return;
|
||||||
|
setEvents((current) => [...current.filter((e) => e.calendarId !== calendarId), ...loaded]);
|
||||||
|
setLoads((current) => ({ ...current, [calendarId]: { status: "ready" } }));
|
||||||
|
} catch (e) {
|
||||||
|
if (generation.current[calendarId] !== mine) return;
|
||||||
|
if (isSessionExpired(e)) {
|
||||||
|
goToAdminLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoads((current) => ({ ...current, [calendarId]: { status: "error", message: describeApiError(e) } }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const calendar of CALENDARS) {
|
||||||
|
void fetchCalendar(calendar.id);
|
||||||
|
}
|
||||||
|
}, [fetchCalendar]);
|
||||||
|
|
||||||
|
/** Reload one calendar, or all. Shows the loading state; existing events stay visible meanwhile. */
|
||||||
|
const reload = useCallback(
|
||||||
|
(calendarId?: number) => {
|
||||||
|
const ids = calendarId === undefined ? CALENDARS.map((c) => c.id) : [calendarId];
|
||||||
|
setLoads((current) => ({ ...current, ...Object.fromEntries(ids.map((id) => [id, { status: "loading" }])) }));
|
||||||
|
for (const id of ids) {
|
||||||
|
void fetchCalendar(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchCalendar],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Re-fetch without flagging the calendar as loading - after a write, to pick up server-side fields. */
|
||||||
|
const refresh = useCallback((calendarId: number) => void fetchCalendar(calendarId), [fetchCalendar]);
|
||||||
|
|
||||||
|
/** Put a saved event into the list (new or changed), replacing any copy with the same id. */
|
||||||
|
const upsertEvent = useCallback((event: CalendarEvent) => {
|
||||||
|
setEvents((current) => [...current.filter((e) => e.eventId !== event.eventId), event]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeEvent = useCallback((eventId: number) => {
|
||||||
|
setEvents((current) => current.filter((e) => e.eventId !== eventId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { events, loads, reload, refresh, upsertEvent, removeEvent };
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { parseListState, toQueryString, type ListState } from "@/lib/url-state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list's filters, read from and written to the query string. `replace`
|
||||||
|
* rather than `push`: flipping a filter should not fill the back button's
|
||||||
|
* history with one entry per click.
|
||||||
|
*
|
||||||
|
* Needs a <Suspense> boundary above it - useSearchParams() in a prerendered
|
||||||
|
* page opts the subtree into client-side rendering.
|
||||||
|
*/
|
||||||
|
export function useListState(): [ListState, (patch: Partial<ListState>) => void] {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const state = useMemo(() => parseListState(new URLSearchParams(searchParams.toString())), [searchParams]);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
(patch: Partial<ListState>) => {
|
||||||
|
const query = toQueryString({ ...state, ...patch });
|
||||||
|
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||||
|
},
|
||||||
|
[state, router, pathname],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, update];
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/** Whether a CSS media query matches, kept current. False during prerendering. */
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(onChange) => {
|
||||||
|
const list = window.matchMedia(query);
|
||||||
|
list.addEventListener("change", onChange);
|
||||||
|
return () => list.removeEventListener("change", onChange);
|
||||||
|
},
|
||||||
|
() => window.matchMedia(query).matches,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckIcon, LinkIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one interactive subscribe option: copies the iCal URL for apps that
|
||||||
|
* want it pasted (Outlook and friends). Takes the shared card classes from the
|
||||||
|
* page so the three options stay identical.
|
||||||
|
*/
|
||||||
|
export function CopyLinkCard({ url, className }: { url: string; className: string }) {
|
||||||
|
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setState("copied");
|
||||||
|
} catch {
|
||||||
|
// No clipboard access (older browser, insecure context): show the URL instead of failing silently.
|
||||||
|
setState("failed");
|
||||||
|
}
|
||||||
|
setTimeout(() => setState("idle"), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={copy} className={className} aria-live="polite">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-10 shrink-0 items-center justify-center rounded-lg transition-colors",
|
||||||
|
state === "copied" ? "bg-emerald-50 text-emerald-600" : "bg-primary/10 text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state === "copied" ? <CheckIcon aria-hidden className="size-5" /> : <LinkIcon aria-hidden className="size-5" />}
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="font-medium">{state === "copied" ? "Link kopiert" : "iCal-Link kopieren"}</span>
|
||||||
|
{/* break-all only for the URL (it has no spaces to wrap at); on words it splits mid-word. */}
|
||||||
|
<span className={cn("text-sm text-muted-foreground", state === "failed" && "break-all")}>
|
||||||
|
{state === "failed" ? url : "Outlook und andere Apps"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-16 items-center justify-center rounded-full bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||||
|
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-4 right-4 bg-secondary"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Schließen</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Schließen
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-36 rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-1 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:bg-input/30 has-[>[data-slot=field]]:rounded-2xl has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-input/40 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-4",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full min-w-0 rounded-3xl border border-transparent bg-input/50 px-3 py-1 text-base transition-[color,box-shadow,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1.5 p-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-3xl border border-transparent bg-input/50 px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow,background-color] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-3 py-2.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none -mx-1.5 my-1.5 h-px bg-border",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/30 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col bg-popover bg-clip-padding text-sm text-popover-foreground shadow-xl transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-4 right-4 bg-secondary"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Schließen</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-2xl bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
// Light only, like the rest of the app (globals.css) - the generated version
|
||||||
|
// followed the OS theme via next-themes, which gave dark toasts on a light page.
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme="light"
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: (
|
||||||
|
<CircleCheckIcon className="size-4" />
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<TriangleAlertIcon className="size-4" />
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<OctagonXIcon className="size-4" />
|
||||||
|
),
|
||||||
|
loading: (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border-2 transition-all outline-none group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-5 data-[size=default]:w-11 data-[size=sm]:h-4 data-[size=sm]:w-7 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary group-has-[:focus-visible]/field-label:data-checked:border-primary data-unchecked:border-transparent data-unchecked:bg-input/90 group-has-[:focus-visible]/field-label:data-unchecked:border-transparent data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background shadow-sm ring-0 transition-transform not-dark:bg-clip-padding group-data-[size=default]/switch:h-4 group-data-[size=default]/switch:w-6 group-data-[size=sm]/switch:h-3 group-data-[size=sm]/switch:w-4 data-checked:translate-x-[calc(100%-8px)] dark:data-checked:bg-primary-foreground data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full resize-none rounded-2xl border border-transparent bg-input/50 px-3 py-3 text-base transition-[color,box-shadow,background-color] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||||
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle"
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
spacing: 2,
|
||||||
|
orientation: "horizontal",
|
||||||
|
})
|
||||||
|
|
||||||
|
function ToggleGroup({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
spacing = 2,
|
||||||
|
orientation = "horizontal",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ToggleGroupPrimitive.Props &
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive
|
||||||
|
data-slot="toggle-group"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
data-spacing={spacing}
|
||||||
|
data-orientation={orientation}
|
||||||
|
style={{ "--gap": spacing } as React.CSSProperties}
|
||||||
|
className={cn(
|
||||||
|
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-[spacing=0]:data-[variant=outline]:rounded-3xl data-vertical:flex-col data-vertical:items-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider
|
||||||
|
value={{ variant, size, spacing, orientation }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroupItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle-group-item"
|
||||||
|
data-variant={context.variant || variant}
|
||||||
|
data-size={context.size || size}
|
||||||
|
data-spacing={context.spacing}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-3 group-data-[spacing=0]/toggle-group:shadow-none focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-2.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-2.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-3xl group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-3xl group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-3xl group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-3xl data-[state=on]:bg-muted group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TogglePrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"group/toggle inline-flex items-center justify-center gap-1 rounded-3xl text-sm font-medium whitespace-nowrap transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border border-input bg-transparent hover:bg-muted",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-9 min-w-9 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||||
|
sm: "h-8 min-w-8 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
lg: "h-10 min-w-10 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle"
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# API wish list
|
||||||
|
|
||||||
|
Changes to the API that would help this app, collected during the rewrite. None of them block it.
|
||||||
|
Done items move to the bottom.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- **List the calendars.** Ids, slugs and names are hardcoded in `lib/calendars.ts` and must be kept in
|
||||||
|
step with `calendarNames` in `events.router.ts`. An endpoint would make a new calendar a data change.
|
||||||
|
- **All calendars in one request.** The admin list makes five parallel requests on every load.
|
||||||
|
- **Restore / history.** Every save already writes a row to `event_versions`, and delete is a
|
||||||
|
`DELETED` version - so "Wiederherstellen" and a change history are possible without schema changes,
|
||||||
|
only endpoints are missing. The UI's delete dialog says it can't be undone "hier".
|
||||||
|
- **Don't return deleted events** from the admin feed (or behind a flag). They arrive as rows with
|
||||||
|
every field `null` except `status`, and the client has to know to drop them.
|
||||||
|
- **Richer recurrence.** `repeatFrequency` goes out as `RRULE:FREQ=<value>` with no interval, end
|
||||||
|
or count. With those, recurrence could become editable in the form (today only Geburtstage get
|
||||||
|
`YEARLY`, automatically).
|
||||||
|
- **A failed update should not be a 200.** `PUT /calendar/events/:id` answers 200
|
||||||
|
`{"message": "0 rows were updated"}` when nothing was written; the client treats that body as an
|
||||||
|
error. A 404/409 would say it properly.
|
||||||
|
- **Explicit time zones.** The API relies on the process running in Berlin time for the iCal output
|
||||||
|
(see the API's `docs/calendar-ical.md`). Setting `TZ` explicitly at start-up, or formatting with an
|
||||||
|
explicit zone, would remove the dependency on the server's configuration.
|
||||||
|
- **`ORGANIZER` as a cal-address** (`ORGANIZER;CN=Name:mailto:…`) instead of a bare name.
|
||||||
|
- **`DB_PORT` for every pool.** Only the admin pool reads it; calendar, feedback and tickets always
|
||||||
|
use 3306.
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
- iCal escaping, line folding and CRLF; `text/calendar` content type (API branch
|
||||||
|
`feature/calendar-ical-escaping-subscriptions`, 2026-09-28).
|
||||||
|
- `GET /calendar/events/subscriptions` for the "Abonnieren" dialog (same branch).
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Deployment and switchover
|
||||||
|
|
||||||
|
How the Next.js app replaces the Angular build on `calendar.nachklang.art`. **The deploy itself is
|
||||||
|
done by a person** (never by Claude). Steps marked *unverified* could not be checked without access
|
||||||
|
to the Plesk server.
|
||||||
|
|
||||||
|
## Order matters
|
||||||
|
|
||||||
|
1. **API first**: merge and deploy the API branch `feature/calendar-ical-escaping-subscriptions`
|
||||||
|
(rebuild + restart; no new env vars - it uses the existing `API_BASE_URL` and
|
||||||
|
`*_CREDENTIAL` values). The old Angular app keeps working against it; the new app's "Abonnieren"
|
||||||
|
dialog needs it.
|
||||||
|
2. **Then the frontend.**
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Node version: `.nvmrc` says 26, copied from the API repo - *unverified* that Plesk offers it; use
|
||||||
|
the version the API runs on.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=https://api.nachklang.art \
|
||||||
|
NEXT_PUBLIC_ADMIN_APP_URL=https://admin.nachklang.art \
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Both values are **inlined at build time** - they must be set for `npm run build`, not just at
|
||||||
|
runtime. (Values from the Angular `environment.prod.ts`.)
|
||||||
|
- `npm run build` runs `postbuild`, which copies `.next/static` into `.next/standalone/.next/static`.
|
||||||
|
Without it the standalone server serves the pages without CSS and JS.
|
||||||
|
- The build fetches the public events from the API to prerender `/`. If it can't reach the API it
|
||||||
|
still succeeds.
|
||||||
|
|
||||||
|
## Run (Plesk Node.js / Passenger)
|
||||||
|
|
||||||
|
- Application startup file: `.next/standalone/server.js`.
|
||||||
|
- The server listens on `PORT` (and `HOSTNAME`). Whether Passenger sets `PORT` itself or needs it
|
||||||
|
configured is *unverified* - check how the feedback app is set up on the same server.
|
||||||
|
- Optional runtime env: `API_BASE_URL` (internal address for the landing page's server-side fetch).
|
||||||
|
- The subdomain changes from serving static files to a Node.js app. *Unverified* how Plesk handles
|
||||||
|
that switch for an existing domain (document root vs. application root).
|
||||||
|
|
||||||
|
Checked locally (2026-09-28): `PORT=4300 node .next/standalone/server.js` serves `/` with events,
|
||||||
|
CSS/JS (200), the favicon, and redirects `/admin` without a cookie to `/admin/login`.
|
||||||
|
|
||||||
|
## Things that must already be true (unchanged from the Angular app)
|
||||||
|
|
||||||
|
The origin stays `https://calendar.nachklang.art`, so nothing should need changing - inferred from
|
||||||
|
the Angular app already using the same cookie auth:
|
||||||
|
|
||||||
|
- the API's CORS list contains `https://calendar.nachklang.art` (verified in `app.factory.ts`);
|
||||||
|
- the admin app's `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS` contains it (otherwise signing in lands on
|
||||||
|
the admin start page instead of coming back);
|
||||||
|
- the API's better-auth trusted origins contain it (otherwise sign-out fails).
|
||||||
|
|
||||||
|
## Smoke test after the switch
|
||||||
|
|
||||||
|
1. `/` loads styled, shows "Nächste Termine", and the Apple link opens a subscription.
|
||||||
|
2. `/admin` while signed out → admin login → back to `/admin` after signing in.
|
||||||
|
3. The list shows all calendars; an edit saves; "Abonnieren" lists links.
|
||||||
|
4. An account without calendar permission sees "Kein Zugriff" (no redirect loop).
|
||||||
|
5. "Abmelden" signs out and offers to sign in again.
|
||||||
|
6. On a phone: list, "+" button, bottom sheet.
|
||||||
|
7. An existing iCal subscription still updates (the feed URL is unchanged).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Keep the last Angular build (`dist/nachklang-calendar`) for about a week and switch the subdomain
|
||||||
|
back to it if needed. The API change is backwards compatible with the Angular app, so it can stay.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Features
|
||||||
|
|
||||||
|
What the calendar app does, as of the Next.js rewrite (September 2026).
|
||||||
|
|
||||||
|
## Public page (`/`)
|
||||||
|
|
||||||
|
- Subscribe to the public calendar: Apple Kalender (`webcal://`), Google Kalender (deep link), or
|
||||||
|
copy the iCal URL (shows the URL if the clipboard isn't available).
|
||||||
|
- "Nächste Termine": the next four public events with date, time and place; an event with a web
|
||||||
|
link opens it. Refreshed every five minutes; hidden if the API can't be reached.
|
||||||
|
- Link to the Verwaltung (`/admin`).
|
||||||
|
|
||||||
|
## Verwaltung (`/admin`)
|
||||||
|
|
||||||
|
Sign-in happens in the admin app (one account for all Nachklang apps). Accounts need the calendar
|
||||||
|
permission; without it the page says so instead of redirecting.
|
||||||
|
|
||||||
|
**List**
|
||||||
|
- All calendars in one list, grouped by month, colour-coded per calendar.
|
||||||
|
- Calendar chips toggle calendars on and off, with a count each; Geburtstage is off by default.
|
||||||
|
- Kommend / Vergangen / Alle. "Vergangen" shows the newest first; "Alle" scrolls to a "Heute" marker.
|
||||||
|
- Search over title and location.
|
||||||
|
- Sort by start or by creation date (the latter as a flat list).
|
||||||
|
- The view is in the URL, so a link reproduces it.
|
||||||
|
- Drafts show an "Entwurf" badge and a "Veröffentlichen" button, which asks for confirmation and
|
||||||
|
says who will see the event.
|
||||||
|
- Repeating events (birthdays) show a "jährlich" badge and appear at their next occurrence.
|
||||||
|
- Failed calendars are marked on their chip with a retry banner; the rest of the list stays usable.
|
||||||
|
|
||||||
|
**Edit sheet** (bottom sheet on phones, side panel on larger screens)
|
||||||
|
- Create ("Neuer Termin" in the header, "+" button on phones), edit, duplicate, delete.
|
||||||
|
- Fields: Kalender, Titel, Ganztägig, Beginn, Ende, Ort, Link, Beschreibung (multi-line), Status
|
||||||
|
(Entwurf / Veröffentlicht; "Privat" is kept but not selectable).
|
||||||
|
- Moving the start moves the end with it. Whole-day events take dates only.
|
||||||
|
- A link without `https://` is completed; anything that isn't a web address is flagged.
|
||||||
|
- Moving an event into Geburtstage makes it yearly; moving it out removes that again.
|
||||||
|
- Errors appear next to their field; a failed save keeps the edits and says why.
|
||||||
|
- Closing with unsaved changes asks first; so does leaving the page.
|
||||||
|
- Shows who created and last changed the event.
|
||||||
|
|
||||||
|
**Abonnieren** (header)
|
||||||
|
- Subscription links for every calendar, including the internal ones with their password: Apple,
|
||||||
|
Google, or copy. Chor and Geburtstage share a password.
|
||||||
|
|
||||||
|
## Not (yet) possible
|
||||||
|
|
||||||
|
- Restoring deleted events and editing recurrence need API work first - see
|
||||||
|
[api-wishlist.md](api-wishlist.md).
|
||||||
|
- A month-grid view was deferred (decision Q6); the agenda list came first.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Gotchas
|
||||||
|
|
||||||
|
Things that turned out differently than expected, so the same wrong assumption isn't made twice.
|
||||||
|
Collected during the rewrite (September 2026); the progress log in
|
||||||
|
[rewrite-plan.md](rewrite-plan.md) has the context for each.
|
||||||
|
|
||||||
|
## The API
|
||||||
|
|
||||||
|
- **PUT replaces the whole event.** Missing fields fall back to `status: 'PUBLIC'` and
|
||||||
|
`repeatFrequency: ''`. Always send the full `EventPayload`, even to change one field.
|
||||||
|
- **"0 rows were updated" comes back as HTTP 200.** `api.updateEvent` turns that body into an error.
|
||||||
|
- **The API returns raw DB values.** `wholeDay` is `0`/`1` (observed), and a deleted event's latest
|
||||||
|
version is a row with every field `null` except `status: 'DELETED'`. `parseApiEvent` handles both.
|
||||||
|
- **`GET /admin/me` answers 200 for accounts without the calendar permission.** Only `apps` says so.
|
||||||
|
- **The admin feed ignores `includes_calendars`**; the public/iCal feeds follow it. So an event can
|
||||||
|
appear in a calendar's subscription although it belongs to another calendar.
|
||||||
|
- **`PRIVATE` is in the status enum but nothing uses it.** The app shows it read-only and keeps it.
|
||||||
|
- **The iCal times depend on the API process's time zone** (formatted with local `getHours()`,
|
||||||
|
labelled Europe/Berlin). Inferred from the code; not checked on the server.
|
||||||
|
- **Only the admin DB pool reads `DB_PORT`.** A local calendar test database has to listen on 3306.
|
||||||
|
|
||||||
|
## Time
|
||||||
|
|
||||||
|
- **Historical DST.** Until 1995 German summer time ended in September, so `1994-10-02T22:00Z` is
|
||||||
|
23:00 on 2 October in Berlin, not midnight on the 3rd. The date code gets it right; hand-written
|
||||||
|
test data easily doesn't.
|
||||||
|
- **DST edges in forms.** A nonexistent spring time (02:30) becomes 03:30 CEST; an ambiguous autumn
|
||||||
|
time resolves to the second occurrence (CET). Pinned in `lib/dates.test.ts`.
|
||||||
|
|
||||||
|
## Next.js / hosting
|
||||||
|
|
||||||
|
- **Standalone output doesn't serve `.next/static` by itself.** Without the copy, CSS and JS are
|
||||||
|
404s. The `postbuild` script copies it into `.next/standalone/.next/static`; there is no `public/`
|
||||||
|
folder to copy (the favicon lives in `app/`). Verified by running `.next/standalone/server.js`.
|
||||||
|
- **`NEXT_PUBLIC_*` values are inlined at build time.** They must be set when `npm run build` runs,
|
||||||
|
not only when the server starts; `lib/env.ts` fails the build if they're missing.
|
||||||
|
- **The build prerenders `/` by calling the API.** If the build machine can't reach it, the build
|
||||||
|
still succeeds and the events section appears after the first revalidation (≤ 5 min).
|
||||||
|
- **With `revalidate`, a short API outage doesn't blank the landing page's events** in a running
|
||||||
|
server - Next keeps serving the cached fetch.
|
||||||
|
- **The dev server must run on port 4200.** That's the origin the API and the admin app trust;
|
||||||
|
another port breaks sign-out and the return redirect.
|
||||||
|
- **Dev runs React strict mode**, so every calendar is fetched twice on load in development.
|
||||||
|
- **A leftover Angular `node_modules`** makes `npm install` fail on a vite peer conflict.
|
||||||
|
- **npm 11 blocks install scripts by default** (`sharp`, `unrs-resolver` didn't run theirs). Lint and
|
||||||
|
build work without them; the app doesn't use `next/image`.
|
||||||
|
- **Vitest 5 needs `@types/node` ≥ 22**; the config is `vitest.config.mts` because `package.json`
|
||||||
|
has no `"type": "module"`.
|
||||||
|
|
||||||
|
## shadcn / Base UI
|
||||||
|
|
||||||
|
- **`npx shadcn add` imports `cn` from the npm package `cn`** and adds it (plus `next-themes` for the
|
||||||
|
toaster). This repo uses `@/lib/utils` like feedback, and toasts are light-only - fix both after
|
||||||
|
every `shadcn add`.
|
||||||
|
- **Generated components have English labels** ("Close"); translated to "Schließen" in `dialog.tsx`
|
||||||
|
and `sheet.tsx`.
|
||||||
|
- **Base UI, not Radix:** toggles expose `aria-pressed` (not `data-state=on`), triggers take a
|
||||||
|
`render` prop (not `asChild`), menu radio items stay open unless `closeOnClick` is set.
|
||||||
|
- **Base UI Select ignores synthetic `click()`s**; automated tests need real pointer or keyboard input.
|
||||||
|
- **Closed dialogs stay in the DOM** with `data-closed` until their exit animation ends - in a hidden
|
||||||
|
tab that never happens. Query `[data-open]` when checking what's open.
|
||||||
|
- **In a hidden tab `matchMedia` `change` events don't fire either**, so a resize there doesn't switch
|
||||||
|
the sheet between bottom and side. It works in a visible window.
|
||||||
|
|
||||||
|
## CSS
|
||||||
|
|
||||||
|
- **Responsive rules inside a capped panel need container queries**, not viewport breakpoints (the
|
||||||
|
edit sheet's footer labels use `@container` / `@md:`).
|
||||||
|
- **An implicit grid column grows to its longest unbreakable content** (a URL) and defeats
|
||||||
|
`truncate`; use `grid-cols-[minmax(0,1fr)]`.
|
||||||
|
- **`break-all` splits ordinary words mid-word** ("App-s"); only use it on text without spaces.
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
# Plan: rewrite the calendar frontend in Next.js
|
||||||
|
|
||||||
|
Status: agreed 2026-09-27; phases 0-8 done 2026-09-28, not yet committed or deployed. Branch: `feature/nextjs-rewrite`.
|
||||||
|
|
||||||
|
Goal: replace the Angular 18 app with a Next.js app that matches `nachklang-feedback` in stack,
|
||||||
|
look and auth behaviour, works properly on phones, and fixes the bugs found in the current
|
||||||
|
implementation instead of porting them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Decisions
|
||||||
|
|
||||||
|
| # | Topic | Decision |
|
||||||
|
|---|-------|----------|
|
||||||
|
| Q1 | Repo | Rewrite in place in `Calendar_Frontend` on `feature/nextjs-rewrite`; the Angular tree is replaced, history keeps it. |
|
||||||
|
| Q2 | Hosting | `output: "standalone"` Node app on Plesk/Passenger, same as feedback. |
|
||||||
|
| Q3 | Stack | Copy feedback exactly: Next 16, React 19, Tailwind 4, shadcn `base-luma` preset `b7ClO5que`, Inter, lucide, ESLint. Port `lib/env.ts`, `lib/admin-auth.ts`, the `adminApi` request/error pattern, `proxy.ts`, and the admin layout's 401/403/unavailable handling. |
|
||||||
|
| Q4 | Language | German only, no i18n. |
|
||||||
|
| Q5 | API scope | Frontend-only, **except** the small API PR in §5 (Q21/Q22). Everything else goes to `docs/api-wishlist.md`. |
|
||||||
|
| Q6 | Admin layout | Agenda list grouped by month; editing happens in a Sheet (bottom drawer on mobile, side panel on desktop). Month grid is a later feature. |
|
||||||
|
| Q7 | Calendars | All calendars at once, colour-coded, toggled with filter chips. Filter state lives in the URL (`?cal=public,choir&range=future&q=…`). Birthdays off by default. |
|
||||||
|
| Q8 | Move | No separate "Move" action: calendar is a form field; the app calls `PUT /move/:id` when it changed. |
|
||||||
|
| Q9 | Landing page | Port the current page, add a "Nächste Termine" list from the anonymous public JSON, read the API URL from env. |
|
||||||
|
| Q10 | Tests | Vitest for pure logic (dates/timezone, whole-day, URL normalisation, validation, filter/sort, calendar mapping). No e2e. |
|
||||||
|
| Q11 | API | Read; findings in §3. |
|
||||||
|
| Q12 | Form | Fields and rules in §4.3. |
|
||||||
|
| Q13 | Libraries | Plain `useState` + pure `validateEvent()` + a small `useEvents` hook. No react-hook-form/zod/TanStack Query. |
|
||||||
|
| Q14 | Recurrence | Not editable. Birthdays get `YEARLY` automatically (moved from `api.service.ts` into form logic), shown as a "jährlich" badge. |
|
||||||
|
| Q15 | Save/delete | Sheet stays open until the API confirms; failure keeps the edits and shows the error; 401 → login. Dirty-close confirmation. Delete via AlertDialog, no undo. Quick "Veröffentlichen" on draft list items. |
|
||||||
|
| Q16 | Extras | All four: duplicate, text search, mobile "+" FAB, iCal-link copy for internal calendars (via the Q22 endpoint). |
|
||||||
|
| Q17 | Cutover | Verify locally on port 4200; the user switches the Plesk subdomain to a Node app and deploys. Old static build kept for ~1 week as rollback. |
|
||||||
|
| Q18 | Docs | Create `docs/architecture.md`, `docs/features.md`, `docs/api-wishlist.md`, `docs/gotchas.md`; update `CLAUDE.md`; add Next `AGENTS.md` note like feedback. |
|
||||||
|
| Q19 | Timezone | All form date handling explicitly in `Europe/Berlin` via `@date-fns/tz`; send UTC ISO strings. |
|
||||||
|
| Q20 | Whole-day | Date-only inputs, inclusive end date; saved as start 00:00 / end 23:59 Berlin. No bulk migration of old rows. |
|
||||||
|
| Q21 | Descriptions | Textarea stays; API PR adds RFC 5545 escaping + line folding to the iCal generator first. |
|
||||||
|
| Q22 | Internal iCal links | New API endpoint `GET /calendar/events/subscriptions` (editors only) returning the subscribe URLs. |
|
||||||
|
| Q23 | Recurring in filters | "Has a `repeatFrequency`" (not "calendar 5") means always current; sorted by next occurrence. |
|
||||||
|
| Q24 | API sequencing | One API PR on its own branch, reviewed and deployed before the frontend goes live. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problems in the Angular app this rewrite fixes
|
||||||
|
|
||||||
|
Found by reading the code on 2026-09-27; do not port these.
|
||||||
|
|
||||||
|
- 14-column table with editing inside the rows; unusable on phones. Toolbar is four `<select>`s joined by ` | `.
|
||||||
|
- All feedback via `window.alert` / `window.confirm`.
|
||||||
|
- Admin UI in English, landing page German.
|
||||||
|
- Calendar column shows the raw id ("5").
|
||||||
|
- One calendar at a time, no search.
|
||||||
|
- **Move dialog:** changes `data.event.calendarId` while you choose, so Cancel still leaves the changed id on the row, and the next Save sends it. The dropdown also has no Birthdays option.
|
||||||
|
- Calendar ids hardcoded three times (`getCalendarId`, the index-based `calendars` array in the move popup, `calendarId === 5` in `api.service.ts` and the filter).
|
||||||
|
- `Date` objects mutated in place, everything in browser local time; whole-day events keep arbitrary times.
|
||||||
|
- Save closes the edit row before the request finishes, so a failed save looks saved.
|
||||||
|
- Changing the filter re-fetches from the server.
|
||||||
|
- URL regex forces `https://` and rewrites the value as a side effect.
|
||||||
|
- Dead `LoginComponent`; landing page hardcodes `api.nachklang.art`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API facts the frontend must respect
|
||||||
|
|
||||||
|
Read from `API/src/models/calendar/events/*` on 2026-09-27.
|
||||||
|
|
||||||
|
- Endpoints under `/calendar/events/`: `GET /:calendar/json` (editors get all statuses incl. `DELETED`,
|
||||||
|
anonymous callers get `PUBLIC` only; `public` needs no credential), `POST /`, `PUT /:id`,
|
||||||
|
`PUT /move/:id`, `DELETE /:id`. Writes need `requireAppAccess('calendar')`.
|
||||||
|
- Calendar ids: public 1, members 2, management 3, choir 4, birthdays 5
|
||||||
|
(`calendarNames` in `events.router.ts`). The frontend keeps these in one typed constant.
|
||||||
|
- **PUT replaces the whole event** and defaults missing fields (`repeatFrequency ?? ''`,
|
||||||
|
`status ?? 'PUBLIC'`). Always send the full event, including for quick-publish.
|
||||||
|
- PUT answering 200 with `"0 rows were updated"` counts as a failure.
|
||||||
|
- Every write inserts into `event_versions`; delete inserts a version with only `status='DELETED'`.
|
||||||
|
The admin feed therefore returns deleted rows; the frontend filters them out.
|
||||||
|
- `PRIVATE` exists in the enum but nothing uses it: show read-only as "Privat", keep it on save.
|
||||||
|
- Admin feed ignores `includes_calendars`; public feeds follow it.
|
||||||
|
- iCal: timed events are written as `TZID=Europe/Berlin` using the server's local `getHours()`,
|
||||||
|
so the API process must run in Berlin time (inferred from the code, not checked on the server).
|
||||||
|
Whole-day end date gets +1 day, i.e. the stored end is the inclusive last day.
|
||||||
|
`repeatFrequency` goes out verbatim as `RRULE:FREQ=…`.
|
||||||
|
- Internal iCal feeds need `?password=`; passwords are env vars (`MEMBER_CREDENTIAL`,
|
||||||
|
`CHOIR_CREDENTIAL` for choir **and** birthdays, `MANAGEMENT_CREDENTIAL`).
|
||||||
|
- CORS already allows `https://calendar.nachklang.art` (`app.factory.ts`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Target design
|
||||||
|
|
||||||
|
### 4.1 Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
layout.tsx root, Inter, lang="de"
|
||||||
|
page.tsx landing page
|
||||||
|
admin/
|
||||||
|
layout.tsx /me gate: 401 → login, 403 → "Kein Zugriff", else "nicht erreichbar"
|
||||||
|
login/page.tsx forwarder page proxy.ts sends signed-out visitors to
|
||||||
|
page.tsx agenda list + sheet
|
||||||
|
components/
|
||||||
|
ui/ shadcn components (sheet, dialog, alert-dialog, badge, button, input,
|
||||||
|
textarea, switch, select, toggle-group, sonner, …)
|
||||||
|
landing/ subscribe cards, upcoming events
|
||||||
|
admin/ EventList, EventListItem, CalendarChips, Toolbar, EventSheet,
|
||||||
|
EventForm, DeleteDialog, SubscriptionLinks, AddEventFab
|
||||||
|
lib/
|
||||||
|
env.ts throws on missing NEXT_PUBLIC_* (copied from feedback)
|
||||||
|
admin-auth.ts login URL / sign-out (copied from feedback)
|
||||||
|
api.ts request helper with credentials:"include", ApiError(status, body)
|
||||||
|
calendars.ts id ↔ slug ↔ label ↔ colour, single source of truth
|
||||||
|
dates.ts Berlin wall-time ↔ UTC, whole-day normalisation, next occurrence
|
||||||
|
event-form.ts Event ↔ form state, validateEvent(), normaliseUrl(), defaults
|
||||||
|
filter.ts range/calendar/search filtering, sorting, month grouping
|
||||||
|
url-state.ts parse/serialise ?cal=&range=&q=&sort=
|
||||||
|
proxy.ts cookie-presence gate on /admin/*
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment: `NEXT_PUBLIC_API_BASE_URL`, `NEXT_PUBLIC_ADMIN_APP_URL` (+ `.env.local.example`).
|
||||||
|
Dev: `next dev -p 4200`. 4200 is required because it's the port the API and admin app trust.
|
||||||
|
|
||||||
|
### 4.2 Admin list
|
||||||
|
|
||||||
|
- Loads all five calendars in parallel through `useEvents`. A calendar that fails to load shows an
|
||||||
|
inline error on its chip and doesn't blank the page. A 401 from any of them → login.
|
||||||
|
- Toolbar: calendar chips (colour dot + label), range toggle (Kommend / Vergangen / Alle),
|
||||||
|
search (title + location), sort (Beginn ↑/↓, Erstellt ↑/↓). Filtering and sorting happen
|
||||||
|
client-side, nothing re-fetched. State lives in the URL.
|
||||||
|
- List grouped by month. Each item shows a date block, title, time or "ganztägig", location,
|
||||||
|
calendar badge, and "Entwurf"/"Privat"/"jährlich" badges. Drafts get a "Veröffentlichen"
|
||||||
|
quick action.
|
||||||
|
- Recurring events (any `repeatFrequency`) always count as current and are sorted by next occurrence.
|
||||||
|
- Mobile: a fixed "+" button bottom-right. Desktop: a "Neuer Termin" button in the toolbar.
|
||||||
|
New events get the calendar pre-filled if exactly one chip is active, start at the next full
|
||||||
|
hour and last 2 h.
|
||||||
|
- Empty, loading (skeleton) and error states are all designed, not blank.
|
||||||
|
|
||||||
|
### 4.3 Event sheet (create / edit / duplicate)
|
||||||
|
|
||||||
|
Fields: Kalender (select) · Titel (required) · Ganztägig (switch) · Beginn/Ende (date, plus time
|
||||||
|
unless whole-day) · Ort · URL · Beschreibung (textarea) · Status (Entwurf / Veröffentlicht; `PRIVATE`
|
||||||
|
shown read-only). Footer: "Erstellt von X am …", "Zuletzt geändert von Y am …".
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Changing the start moves the end by the same amount, so the event's length stays the same.
|
||||||
|
An end before the start is an error on the field.
|
||||||
|
- Whole-day: time inputs hidden, their values discarded; saved as 00:00 / 23:59 Berlin, inclusive end.
|
||||||
|
- URL: if there's no scheme, `https://` is added on save; anything `new URL()` rejects is a field error.
|
||||||
|
Nothing is rewritten while the user types.
|
||||||
|
- Birthdays calendar ⇒ `repeatFrequency = 'YEARLY'`. Any other existing `repeatFrequency` is kept.
|
||||||
|
- Errors appear next to their field. No alerts.
|
||||||
|
|
||||||
|
Save flow: button shows a spinner; the sheet stays open until done. For an existing event whose
|
||||||
|
calendar changed: `PUT /move/:id`, then `PUT /:id` with the full event. If only the first one
|
||||||
|
succeeds, say exactly that. Success → close + toast + update local state. Failure → stay open
|
||||||
|
with the edits and show the message. 401 → toast "Sitzung abgelaufen", then login.
|
||||||
|
|
||||||
|
Other actions in the sheet: Duplizieren (opens a new draft copy, no id, "(Kopie)" suffix),
|
||||||
|
Löschen (AlertDialog). Closing with unsaved changes asks for confirmation.
|
||||||
|
|
||||||
|
### 4.4 iCal subscription links (admin)
|
||||||
|
|
||||||
|
A "Abonnieren" dialog in the admin header lists each calendar's subscribe URL from
|
||||||
|
`GET /calendar/events/subscriptions`, with copy buttons plus Apple/Google deep links. Notes that
|
||||||
|
choir and birthdays share a password.
|
||||||
|
|
||||||
|
### 4.5 Landing page
|
||||||
|
|
||||||
|
Same content as today (Apple / Google / copy-link cards, German copy), rebuilt with the design
|
||||||
|
system. Adds "Nächste Termine": the next ~5 `PUBLIC` events from `/calendar/events/public/json`,
|
||||||
|
server-rendered with a short revalidate. If the API is down, the section is hidden and the rest
|
||||||
|
of the page still works. All URLs come from env.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API PR (separate branch in the API repo, ships first)
|
||||||
|
|
||||||
|
1. `icalgenerator.service.ts`: RFC 5545 text escaping (`\\`, `;`, `,`, newline → `\n`) for
|
||||||
|
SUMMARY/DESCRIPTION/LOCATION, and line folding at 75 octets. Also stop `formatDate` from
|
||||||
|
mutating the event's `Date` (it calls `setDate` on it for the whole-day end).
|
||||||
|
2. `GET /calendar/events/subscriptions` behind `requireAppAccess('calendar')`: returns
|
||||||
|
`{calendar, label, icalUrl}` per calendar with the configured password. It leaves out a calendar
|
||||||
|
whose credential is unset instead of returning an unusable URL.
|
||||||
|
3. Tests for both (the API uses Vitest; run against the Docker test setup).
|
||||||
|
|
||||||
|
Validate locally; the user reviews and deploys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Phases
|
||||||
|
|
||||||
|
Each phase ends in a working, reviewable state. No commits without the user's approval.
|
||||||
|
|
||||||
|
0. **Scaffold.** `create-next-app` output shaped like feedback; copy the design-system files
|
||||||
|
(`globals.css`, `components.json`, `lib/utils.ts`), ESLint and Vitest. Removing the Angular files
|
||||||
|
(`src/`, `angular.json`, `karma.conf.js`, `tsconfig.*`, `.browserslistrc`) is a deletion, so
|
||||||
|
**ask the user before doing it**. Add `.claude/launch.json` for port 4200. Pin the Node version to
|
||||||
|
what Plesk offers.
|
||||||
|
1. **Foundations + tests.** `env`, `admin-auth`, `api`, `calendars`, `dates`, `event-form`, `filter`,
|
||||||
|
`url-state`, with Vitest covering DST boundaries, whole-day normalisation, URL normalisation,
|
||||||
|
validation, next-occurrence sorting, and deleted/recurring filtering.
|
||||||
|
2. **Auth shell.** `proxy.ts`, `/admin/login` forwarder, admin layout with the three failure states
|
||||||
|
and the header (name, Abonnieren, Abmelden).
|
||||||
|
3. **List.** `useEvents`, chips, range/search/sort, month grouping, URL state, quick-publish,
|
||||||
|
loading/empty/error states.
|
||||||
|
4. **Sheet.** Form, validation, save/move flow, delete, duplicate, dirty guard, FAB.
|
||||||
|
5. **Landing page.**
|
||||||
|
6. **API PR** (can run in parallel with phases 1–5), then the subscriptions dialog.
|
||||||
|
7. **Verification.** Local API on :3000 with a throwaway DB (Docker) and the admin app on :3002.
|
||||||
|
Check sign-in, return redirect, 401/403 states, and every CRUD path. Check at 375 px and on a
|
||||||
|
real phone over LAN (`allowedDevOrigins`). Run `next build` with and without env vars.
|
||||||
|
8. **Docs + cutover.** Write the docs from Q18, update `CLAUDE.md`, open the PR. The user converts
|
||||||
|
`calendar.nachklang.art` to a Node app in Plesk and deploys, keeping the old static build for ~1 week.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress log
|
||||||
|
|
||||||
|
**Phase 0 - done 2026-09-27 (uncommitted).** Angular files removed with `git rm` (approved);
|
||||||
|
`favicon.ico` moved to `app/`. Config copied from feedback. `npm install`, `npm test`, `npm run lint`,
|
||||||
|
`npm run build` pass; the placeholder page renders on :4200 without console errors.
|
||||||
|
Deviations and surprises (move these to `docs/gotchas.md` in phase 8):
|
||||||
|
- `@types/node` is `^26`, not feedback's `^20`: Vitest 5 has a peer dependency on `@types/node` ≥ 22,
|
||||||
|
and npm refuses `^20`. 26 matches `.nvmrc`.
|
||||||
|
- A leftover Angular `node_modules/` makes `npm install` fail on a vite peer conflict. It has to be
|
||||||
|
deleted by hand when switching branches between Angular and Next.
|
||||||
|
- npm 11 now blocks install scripts by default: `sharp` (next/image) and `unrs-resolver` (ESLint
|
||||||
|
import resolution) did **not** run their install scripts. Lint and build pass without them;
|
||||||
|
whether the standalone server needs `sharp` on Plesk is open (we don't use `next/image` yet).
|
||||||
|
- `.nvmrc` = 26 is taken from the API repo, not from what Plesk offers.
|
||||||
|
|
||||||
|
**Phase 1 - done 2026-09-27 (uncommitted).** `lib/`: `types`, `calendars`, `dates`, `events`
|
||||||
|
(parsing), `event-form`, `filter`, `url-state`, `env`, `admin-auth`, `api`. 87 Vitest tests in
|
||||||
|
6 files, run with `TZ=America/New_York` so Berlin handling can't pass by accident. `tsc`,
|
||||||
|
lint and build are clean.
|
||||||
|
Decisions made while implementing (not asked explicitly - revisit if wrong):
|
||||||
|
- Moving an event **out of** Birthdays drops its `YEARLY` recurrence, so a moved birthday doesn't
|
||||||
|
become a yearly event somewhere else. A recurrence from any other source is kept.
|
||||||
|
- Recurring events appear under "Kommend" and "Alle", never under "Vergangen".
|
||||||
|
- A yearly event on 29 Feb falls on 28 Feb in common years.
|
||||||
|
- An unknown `status` from the API is treated (and saved back) as `PRIVATE`, never as `PUBLIC`.
|
||||||
|
- Calendar labels: Öffentlich, Mitglieder, Chor, Management, Geburtstage.
|
||||||
|
- New events default to `DRAFT`, like the Angular app.
|
||||||
|
Facts found along the way (move to `docs/gotchas.md` in phase 8):
|
||||||
|
- The API returns raw DB rows: `wholeDay` is most likely `0`/`1` (inferred from the MariaDB
|
||||||
|
driver's defaults, not observed), and a deleted event's row has `null` in every column except
|
||||||
|
`status`. `lib/events.ts` coerces both.
|
||||||
|
- `@date-fns/tz` resolves a nonexistent spring-forward time (02:30) to 03:30 CEST, and an
|
||||||
|
ambiguous autumn time to the second occurrence (CET). Pinned in `lib/dates.test.ts`.
|
||||||
|
- The Vitest config is `vitest.config.mts`, not `.ts`: Vite warns about ESM in a CommonJS-loaded
|
||||||
|
file because `package.json` has no `"type": "module"` (Next doesn't need one).
|
||||||
|
|
||||||
|
**Phase 2 - done 2026-09-28 (uncommitted).** `proxy.ts`, `/admin/login` forwarder,
|
||||||
|
`app/admin/layout.tsx` (/me gate, header, loading skeleton, failure screens), `AdminSession`
|
||||||
|
context, placeholder `/admin` page. shadcn components added under `components/ui/`.
|
||||||
|
Verified in the browser against a local API + throwaway MariaDB (podman; setup below):
|
||||||
|
signed out → forwarder → admin login with the full return URL; `/admin/login` while signed in →
|
||||||
|
straight back with the query kept; signed in → name in header; no calendar permission → "Kein
|
||||||
|
Zugriff", no redirect; API down → "nicht erreichbar", no redirect; stale cookie → 401 → login;
|
||||||
|
sign-out → POST 200 → login with return URL; 375 px header.
|
||||||
|
Facts found along the way (move to `docs/gotchas.md` in phase 8):
|
||||||
|
- `wholeDay` **is** `0`/`1` on the wire (observed; confirms the phase-1 guess).
|
||||||
|
- `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, a 403 alone won't come.
|
||||||
|
- The dev session cookie is `nachklang.session_token` (no `__Secure-` prefix over http). Cookies
|
||||||
|
ignore the port, so the one the API sets on `localhost:3000` reaches `localhost:4200` too.
|
||||||
|
- The shadcn CLI (v4.19) generated imports from the npm package `cn` (shadcn's own
|
||||||
|
clsx+tailwind-merge replacement) and installed it, plus `next-themes` for the toaster. Both were
|
||||||
|
removed: components import `cn` from `@/lib/utils` like feedback, and toasts are pinned to light.
|
||||||
|
**Re-check this after every `shadcn add`.**
|
||||||
|
- In the API, only the admin DB pool reads `DB_PORT`; the calendar pool always uses 3306. A local
|
||||||
|
test DB therefore has to listen on 3306.
|
||||||
|
|
||||||
|
**Phase 3 - done 2026-09-28 (uncommitted).** The event list: `useEvents` (5 calendars in parallel,
|
||||||
|
per-calendar loading/error, request counter against stale responses), `useListState` (URL ↔ filters),
|
||||||
|
`EventsToolbar` (debounced search, sort menu, reload, range toggle), `CalendarChips` (scrolling row on
|
||||||
|
phones, per-chip counts, load/error markers), `EventList` / `EventListItem` (month groups with sticky
|
||||||
|
headings, flat list for "Erstellt" sorts, date block, badges, past events dimmed, quick
|
||||||
|
"Veröffentlichen"), empty/loading/error states. New pure modules `lib/event-display.ts` and
|
||||||
|
`lib/api-errors.ts`; `toQueryString` keeps `cal=public,choir` readable. 97 tests.
|
||||||
|
Verified in the browser with test events across all calendars (desktop and 375 px, no horizontal
|
||||||
|
overflow): Birthdays chip → URL + next-occurrence sorting; search with umlauts over title + location;
|
||||||
|
empty state + "Suche leeren" also clears the box; range toggle can't be emptied; "Vergangen" excludes
|
||||||
|
birthdays; sort menu → flat list, closes on pick; quick publish → PUT with the full event, DB keeps
|
||||||
|
times/recurrence, new version row; API down → per-chip error markers + banner, loaded events stay.
|
||||||
|
Decisions made while implementing (not asked explicitly):
|
||||||
|
- Rows open nothing yet; the stretched title button appears once phase 4 passes `onOpen`.
|
||||||
|
|
||||||
|
**Phase 3 follow-ups - decided by the user 2026-09-28, done.**
|
||||||
|
- "Veröffentlichen" asks first (`PublishDialog`): shows date, time and calendar, and says who will
|
||||||
|
see it (public calendar → nachklang.art and all subscribers). Cancel sends nothing; the dialog stays
|
||||||
|
open with a spinner until the API answers, and stays open on failure for a retry.
|
||||||
|
- "Vergangen" defaults to newest first. The default sort now depends on the range
|
||||||
|
(`defaultSortFor`); `withRange` swaps a *default* sort along when the range changes, while a sort
|
||||||
|
someone picked is kept. Defaults stay out of the URL (`?range=past`, not `&sort=start_desc`).
|
||||||
|
- "Alle" scrolls to today: a "Heute · <Datum>" marker sits where the past ends
|
||||||
|
(`todayMarkerIndex`), and the page centres it when "Alle" opens and again after a change of
|
||||||
|
calendars or sort - not on search keystrokes. Smooth scrolling unless the user prefers reduced motion.
|
||||||
|
(The marker itself was Claude's addition to the request.)
|
||||||
|
Facts found along the way (move to `docs/gotchas.md` in phase 8):
|
||||||
|
- Historical DST matters for birthdays: until 1995 German summer time ended in **September**, so an
|
||||||
|
instant like `1994-10-02T22:00Z` is 23:00 on 2 Oct in Berlin, not midnight on 3 Oct. The date code
|
||||||
|
handles this correctly; hand-written test data easily gets it wrong.
|
||||||
|
- Next dev runs React strict mode, so every calendar is fetched twice on load in dev (once in prod).
|
||||||
|
- Base UI menu radio items stay open after a pick unless `closeOnClick` is set.
|
||||||
|
- Base UI toggles expose `aria-pressed`, not Radix's `data-state=on`; style pressed items with
|
||||||
|
`aria-pressed:`.
|
||||||
|
|
||||||
|
**Phase 4 - done 2026-09-28 (uncommitted).** `EventSheet` (create / edit / duplicate; bottom sheet
|
||||||
|
below 640 px, side panel above), `EventForm` (presentational; rules stay in `lib/event-form.ts`),
|
||||||
|
delete confirmation, discard-changes confirmation (+ `beforeunload` while dirty), "Neuer Termin" in
|
||||||
|
the header from `sm`, floating "+" below it. New pure helpers `contentChanged` and `eventFromPayload`.
|
||||||
|
107 tests.
|
||||||
|
Save flow as built: validate → focus the first invalid field; new → POST; edit → `PUT /move/:id` if
|
||||||
|
the calendar changed, then `PUT /:id` only if anything else changed; sheet closes only after the API
|
||||||
|
answered; a failure keeps the sheet and the edits and shows the reason inline; move-OK-update-failed
|
||||||
|
records the move and says so; 401 → toast + login. After a save the list is updated at once, then the
|
||||||
|
affected calendar(s) re-fetched; a saved event hidden by the current filters gets an info toast.
|
||||||
|
Verified against the local API (DB checked after each write): edit keeps untouched fields; start
|
||||||
|
change moves the end; validation + focus, no request; whole-day → 00:00-23:59; link without scheme →
|
||||||
|
`https://…`; pure move = 1 request; move into Birthdays = move + update with `YEARLY`; duplicate
|
||||||
|
→ "(Kopie)", draft, POST; delete with cancel/confirm; API down → inline error, edits kept, retry works;
|
||||||
|
375 px: bottom sheet at 92dvh, icon-only secondary buttons, nothing overflows.
|
||||||
|
Decisions made while implementing (not asked explicitly):
|
||||||
|
- "Duplizieren" is disabled while the form has unsaved changes (it would silently drop them).
|
||||||
|
- A new event gets the calendar preselected only when exactly one chip is active; otherwise the
|
||||||
|
user must pick one.
|
||||||
|
- An unchanged form closes without asking; any change asks before discarding.
|
||||||
|
Fix after user review (2026-09-28): the footer buttons were clipped in the desktop side panel.
|
||||||
|
Cause: "Löschen"/"Duplizieren" labels appeared from the `sm` *viewport* width, but the panel is
|
||||||
|
capped (448 px) regardless of the window. Now the panel is up to 512 px (`sm:max-w-lg`) and the
|
||||||
|
labels follow the footer's own width via a container query (`@container` / `@md:inline`), falling
|
||||||
|
back to icons with tooltips. Measured: 1024, 700, 640 and 500 px show labels and fit; 375 px shows
|
||||||
|
icons and fits.
|
||||||
|
Facts found along the way (move to `docs/gotchas.md` in phase 8):
|
||||||
|
- Base UI Select ignores synthetic `click()`s; it needs real pointer or keyboard input (relevant for
|
||||||
|
any future automated UI test).
|
||||||
|
- Base UI dialogs and sheets stay in the DOM with `data-closed`/`data-ending-style` until their exit
|
||||||
|
animation ends; in a hidden tab that never happens. Query `[data-open]` when checking what's open.
|
||||||
|
- In a hidden tab, `matchMedia` `change` events don't fire either (they're dispatched during
|
||||||
|
rendering), so a viewport resize there doesn't switch the sheet between bottom and side. Reload at
|
||||||
|
the target width when testing that way; in a visible window it switches live.
|
||||||
|
- Responsive rules inside a capped panel must use container queries, not viewport breakpoints.
|
||||||
|
|
||||||
|
**Phase 5 - done 2026-09-28 (uncommitted).** Landing page (`app/page.tsx`, server component).
|
||||||
|
First built in the dark/gold look of the Angular redesign (commit 6400616); **the user then asked for
|
||||||
|
it to match the rest of the app, more modern and simpler**, so it now uses the design system like the
|
||||||
|
admin area: light, Inter, a header with "Nachklang · Kalender" and a "Verwaltung" link, one heading
|
||||||
|
and one sentence, the three subscribe options as cards (a row each on phones, icon above text in the
|
||||||
|
three-column grid from `sm`), "Nächste Termine" in the admin list's style, a small footer. No
|
||||||
|
clef, staff lines, gradients or entrance animation any more.
|
||||||
|
Subscribe links built from `NEXT_PUBLIC_API_BASE_URL` (`lib/public-calendar.ts`), no longer
|
||||||
|
hardcoded. "Nächste Termine": the next 4 public events, fetched server-side with
|
||||||
|
`next: { revalidate: 300 }`, page `revalidate = 300`; an event's link is only rendered if it passes
|
||||||
|
`normaliseUrl` (http/https), since legacy rows were never validated. Optional server-only
|
||||||
|
`API_BASE_URL` like in feedback. `CopyLinkCard` shows the URL if the clipboard is unavailable.
|
||||||
|
111 tests.
|
||||||
|
Verified: SSR HTML contains the events and env-based links; 1024 px three equal cards, 375 px
|
||||||
|
full-width rows, no overflow; copy puts the iCal URL on the clipboard; `next build` with the API
|
||||||
|
down succeeds and prerenders the page without the events section.
|
||||||
|
Facts found along the way (move to `docs/gotchas.md` in phase 8):
|
||||||
|
- With `revalidate`, a short API outage doesn't blank the events section in a running server: Next
|
||||||
|
keeps serving the cached fetch. Only a cache miss (e.g. a build with the API down) hides it.
|
||||||
|
- The build prerenders `/` by calling the API from the build machine; on Plesk that's the server
|
||||||
|
itself. If it can't reach the API the build still succeeds, and the section appears after the first
|
||||||
|
revalidation.
|
||||||
|
|
||||||
|
**Phase 6 - done 2026-09-28 (uncommitted, both repos).**
|
||||||
|
API, branch `feature/calendar-ical-escaping-subscriptions` in the API repo (documented in the API's
|
||||||
|
`docs/calendar-ical.md`):
|
||||||
|
- iCal export: RFC 5545 text escaping for SUMMARY/DESCRIPTION/LOCATION, line folding at 75 octets
|
||||||
|
(UTF-8 aware), CRLF line endings; `formatDate` no longer mutates the event's end date.
|
||||||
|
- `GET /calendar/events/subscriptions` (editors only, shared password not accepted, `no-store`):
|
||||||
|
iCal URL per calendar, password included and URL-encoded; calendars without a configured
|
||||||
|
credential are omitted. Built from the API's validated `API_BASE_URL`.
|
||||||
|
- Tests: new `test/calendar/icalgenerator.service.test.ts` (11, all fail against the old
|
||||||
|
generator), 4 subscription cases in `events.router.test.ts`. Full API suite 217/217, `tsc` clean.
|
||||||
|
Live check against the local API: CRLF only, max 75 octets per line, a multi-line description
|
||||||
|
with `,`/`;`/umlauts stays one escaped `DESCRIPTION`.
|
||||||
|
Frontend: `SubscriptionsDialog` ("Abonnieren" in the admin header, icon-only on phones): per calendar
|
||||||
|
the URL, Apple (webcal) and Google links and "Link kopieren"; fetched on every open; note that choir
|
||||||
|
and birthdays share a password. `toWebcal`/`toGoogleCalendar` now work for any iCal URL (password
|
||||||
|
survives both conversions, tested). shadcn's English "Close" labels translated to "Schließen".
|
||||||
|
Found along the way:
|
||||||
|
- The iCal endpoint answers with `Content-Type: text/html`, not `text/calendar` (pre-existing, not
|
||||||
|
changed - calendar apps have coped). Candidate for the API wish list or a one-line fix.
|
||||||
|
- `ORGANIZER` is written as a bare name instead of a cal-address (pre-existing, not changed).
|
||||||
|
- A CSS grid's implicit column grows to its longest unbreakable content (a URL), which defeats
|
||||||
|
`truncate` inside it; `grid-cols-[minmax(0,1fr)]` fixes it (`SubscriptionsDialog`).
|
||||||
|
|
||||||
|
**Phase 6 follow-up (2026-09-28, user request):** the iCal endpoint now sends
|
||||||
|
`Content-Type: text/calendar; charset=utf-8` (API branch, +1 router test, 218/218; checked live).
|
||||||
|
|
||||||
|
**Phase 7 (verification)** happened within each phase rather than as its own step: every flow was
|
||||||
|
exercised in the browser against the local API with DB checks. Not done: a real phone over LAN (the
|
||||||
|
app calls `localhost:3000`, which a phone can't reach without changing the env), and a visual check
|
||||||
|
by Claude (the browser pane was hidden for most of the session - the user checked visually).
|
||||||
|
|
||||||
|
**Phase 8 - done 2026-09-28 (uncommitted).** Docs written: `architecture.md`, `features.md`,
|
||||||
|
`gotchas.md` (the progress-log findings, consolidated), `api-wishlist.md`, `deployment.md`
|
||||||
|
(order: API first; build env; standalone start; smoke test; rollback). `CLAUDE.md` rewritten for
|
||||||
|
Next; README links the docs. Deployment prep: standalone output doesn't serve `.next/static` on its
|
||||||
|
own, so `postbuild` now copies it; verified by running `.next/standalone/server.js` on :4300
|
||||||
|
(styled page, CSS/JS 200, `/admin` redirect).
|
||||||
|
|
||||||
|
Local test environment (throwaway, nothing points at shared infra):
|
||||||
|
- `podman run -d --name nk-calendar-test -p 3306:3306 -e TZ=Europe/Berlin -e MARIADB_ROOT_PASSWORD=… --tmpfs /var/lib/mysql -v API/docker/init:/docker-entrypoint-initdb.d:ro -v API/sql:/migrations:ro mariadb:11`
|
||||||
|
- in-memory, seeded by the API's own dev scripts, incl. the `dev@nachklang.art` account.
|
||||||
|
- API: `npx tsc` in the API repo, then `node API/dist/app.js` started **from a scratch directory**
|
||||||
|
holding its own `.env` (dotenv reads the cwd), with `TZ=Europe/Berlin`. The API repo's own `.env`
|
||||||
|
is never loaded. The API's logger still writes to `API/logs/` (gitignored).
|
||||||
|
- Sign in by POSTing the seeded credentials to `http://localhost:3000/admin/auth/sign-in/email`
|
||||||
|
from a page on `localhost:4200`; the admin app isn't needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open risks
|
||||||
|
|
||||||
|
- Plesk subdomain conversion from static to Node is unverified for this domain. (Corrected: an
|
||||||
|
earlier version said feedback proves the toolkit works on the server - feedback's own plan records
|
||||||
|
its Plesk deploy as blocked and never documents how it runs, so that was an unsourced claim.)
|
||||||
|
- The API server's timezone is inferred, not checked. If it isn't Berlin, the iCal times are already
|
||||||
|
wrong today, independent of this rewrite.
|
||||||
|
- `lastModifiedBy`/`createdBy` names can come back empty for legacy rows (nullable since migration 003);
|
||||||
|
the footer has to handle that.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// Karma configuration file, see link for more information
|
|
||||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
|
||||||
|
|
||||||
module.exports = function (config) {
|
|
||||||
config.set({
|
|
||||||
basePath: '',
|
|
||||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
|
||||||
plugins: [
|
|
||||||
require('karma-jasmine'),
|
|
||||||
require('karma-chrome-launcher'),
|
|
||||||
require('karma-jasmine-html-reporter'),
|
|
||||||
require('karma-coverage'),
|
|
||||||
require('@angular-devkit/build-angular/plugins/karma')
|
|
||||||
],
|
|
||||||
client: {
|
|
||||||
jasmine: {
|
|
||||||
// you can add configuration options for Jasmine here
|
|
||||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
|
||||||
// for example, you can disable the random execution with `random: false`
|
|
||||||
// or set a specific seed with `seed: 4321`
|
|
||||||
},
|
|
||||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
|
||||||
},
|
|
||||||
jasmineHtmlReporter: {
|
|
||||||
suppressAll: true // removes the duplicated traces
|
|
||||||
},
|
|
||||||
coverageReporter: {
|
|
||||||
dir: require('path').join(__dirname, './coverage/nachklang-calendar'),
|
|
||||||
subdir: '.',
|
|
||||||
reporters: [
|
|
||||||
{ type: 'html' },
|
|
||||||
{ type: 'text-summary' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
reporters: ['progress', 'kjhtml'],
|
|
||||||
port: 9876,
|
|
||||||
colors: true,
|
|
||||||
logLevel: config.LOG_INFO,
|
|
||||||
autoWatch: true,
|
|
||||||
browsers: ['Chrome'],
|
|
||||||
singleRun: false,
|
|
||||||
restartOnFileChange: true
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ADMIN_APP_URL, API_BASE_URL } from "@/lib/env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where signing in and out happens - which is never here. Mirrors
|
||||||
|
* `lib/admin-auth.ts` in nachklang-feedback; keep the two in step.
|
||||||
|
*
|
||||||
|
* The session is an httpOnly cookie that the *API* sets on `.nachklang.art`,
|
||||||
|
* shared by the calendar, feedback, tickets and admin apps. This app can
|
||||||
|
* neither read nor mint it, so a 401 is not something it can fix by
|
||||||
|
* re-authenticating: it hands the user over to admin.nachklang.art and lets
|
||||||
|
* that app decide.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin app's login URL, carrying an absolute `?redirect=` back to where
|
||||||
|
* the user is now. The admin app checks that target against its own
|
||||||
|
* allow-list by exact origin, so an origin missing from its
|
||||||
|
* NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS lands the user on the admin start page
|
||||||
|
* rather than back here - which is why the dev server has to run on :4200.
|
||||||
|
*/
|
||||||
|
export const adminLoginUrl = (returnTo?: string): string => {
|
||||||
|
const target =
|
||||||
|
returnTo ?? (typeof window === "undefined" ? "/admin" : `${window.location.pathname}${window.location.search}`);
|
||||||
|
const absolute = typeof window === "undefined" ? target : new URL(target, window.location.origin).href;
|
||||||
|
return `${ADMIN_APP_URL}/login?redirect=${encodeURIComponent(absolute)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard navigation, not router.push: every target here is a different origin.
|
||||||
|
* Kept behind a helper so the destination is a value rather than a template
|
||||||
|
* literal at the assignment - otherwise Next's
|
||||||
|
* no-location-assign-relative-destination rule reads an absolute URL built from
|
||||||
|
* an env var as an internal route.
|
||||||
|
*/
|
||||||
|
const leaveApp = (url: string): void => {
|
||||||
|
window.location.href = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const goToAdminLogin = (returnTo?: string): void => {
|
||||||
|
leaveApp(adminLoginUrl(returnTo));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs out of every Nachklang app at once - the cookie is shared, so there is
|
||||||
|
* no signing out of the calendar alone. The redirect happens even if the call
|
||||||
|
* fails: leaving someone on an admin page that will 401 on its next request is
|
||||||
|
* worse than a login form they may not have needed.
|
||||||
|
*
|
||||||
|
* The login URL carries this page as the return target, so an accidental
|
||||||
|
* sign-out costs one sign-in to undo.
|
||||||
|
*/
|
||||||
|
export const signOut = async (): Promise<void> => {
|
||||||
|
// Captured before the request, so the target is where the user actually was.
|
||||||
|
const target = adminLoginUrl();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE_URL}/admin/auth/sign-out`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Network failure: fall through to the redirect anyway.
|
||||||
|
}
|
||||||
|
leaveApp(target);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to tell an editor when a call fails. German, one sentence, no status
|
||||||
|
* codes up front - the code is appended for anything unexpected, so a
|
||||||
|
* screenshot of the message is still useful to whoever debugs it.
|
||||||
|
*
|
||||||
|
* 401 is listed for completeness; callers should send the user to the login
|
||||||
|
* page instead of showing it (see `isSessionExpired`).
|
||||||
|
*/
|
||||||
|
export const describeApiError = (error: unknown): string => {
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
return "Unerwarteter Fehler. Bitte versuche es erneut.";
|
||||||
|
}
|
||||||
|
switch (error.status) {
|
||||||
|
case 0:
|
||||||
|
return "Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung.";
|
||||||
|
case 401:
|
||||||
|
return "Deine Sitzung ist abgelaufen.";
|
||||||
|
case 403:
|
||||||
|
return "Dein Konto hat keine Berechtigung (mehr) für den Kalender.";
|
||||||
|
case 200:
|
||||||
|
// A 200 that the client decided was a failure (e.g. "0 rows were updated") carries its own German message.
|
||||||
|
return error.message;
|
||||||
|
default:
|
||||||
|
return `Der Server hat einen Fehler gemeldet (${error.status}). Bitte versuche es erneut.`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Nobody is signed in any more - the one case worth a trip to the login page. */
|
||||||
|
export const isSessionExpired = (error: unknown): boolean => error instanceof ApiError && error.status === 401;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { api, ApiError } from "@/lib/api";
|
||||||
|
import type { EventPayload } from "@/lib/types";
|
||||||
|
|
||||||
|
const payload: EventPayload = {
|
||||||
|
calendarId: 1,
|
||||||
|
name: "Konzert",
|
||||||
|
description: "",
|
||||||
|
startDateTime: "2026-10-03T17:00:00.000Z",
|
||||||
|
endDateTime: "2026-10-03T19:00:00.000Z",
|
||||||
|
location: "",
|
||||||
|
url: "",
|
||||||
|
wholeDay: false,
|
||||||
|
repeatFrequency: "",
|
||||||
|
status: "DRAFT",
|
||||||
|
};
|
||||||
|
|
||||||
|
const respond = (status: number, body?: unknown) =>
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("api", () => {
|
||||||
|
it("always sends the session cookie", async () => {
|
||||||
|
const fetch = respond(200, { id: "u", email: "e", fullName: "Name", apps: ["calendar"] });
|
||||||
|
vi.stubGlobal("fetch", fetch);
|
||||||
|
await api.me();
|
||||||
|
expect(fetch).toHaveBeenCalledWith("https://api.test/admin/me", expect.objectContaining({ credentials: "include" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 401 and 403 apart", async () => {
|
||||||
|
vi.stubGlobal("fetch", respond(401, { message: "Anmeldung erforderlich." }));
|
||||||
|
await expect(api.me()).rejects.toMatchObject({ status: 401 });
|
||||||
|
vi.stubGlobal("fetch", respond(403, { message: "nope" }));
|
||||||
|
await expect(api.me()).rejects.toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a network failure as status 0", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||||
|
const error = await api.me().catch((e: unknown) => e);
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
expect((error as ApiError).status).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses events and drops deleted ones", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
respond(200, [
|
||||||
|
{ eventId: 1, calendarId: 1, uuid: "a", name: "A", description: "", startDateTime: payload.startDateTime, endDateTime: payload.endDateTime, createdDate: null, location: "", url: "", wholeDay: 0, repeatFrequency: "", status: "PUBLIC" },
|
||||||
|
{ eventId: 2, calendarId: 1, uuid: "b", name: null, description: null, startDateTime: null, endDateTime: null, createdDate: null, location: null, url: null, wholeDay: null, repeatFrequency: null, status: "DELETED" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const events = await api.getEvents("public");
|
||||||
|
expect(events.map((e) => e.eventId)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the new id on create", async () => {
|
||||||
|
const fetch = respond(201, { message: "ok", eventId: 42 });
|
||||||
|
vi.stubGlobal("fetch", fetch);
|
||||||
|
await expect(api.createEvent(payload)).resolves.toBe(42);
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an update that wrote nothing as a failure", async () => {
|
||||||
|
vi.stubGlobal("fetch", respond(200, { message: "0 rows were updated" }));
|
||||||
|
await expect(api.updateEvent(1, payload)).rejects.toBeInstanceOf(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves and deletes by id", async () => {
|
||||||
|
const fetch = respond(200, { message: "ok" });
|
||||||
|
vi.stubGlobal("fetch", fetch);
|
||||||
|
await api.moveEvent(9, { ...payload, calendarId: 4 });
|
||||||
|
await api.deleteEvent(9);
|
||||||
|
expect(fetch.mock.calls.map((c) => [c[0], c[1].method])).toEqual([
|
||||||
|
["https://api.test/calendar/events/move/9", "PUT"],
|
||||||
|
["https://api.test/calendar/events/9", "DELETE"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
import { calendarById, type CalendarSlug } from "@/lib/calendars";
|
||||||
|
import { API_BASE_URL } from "@/lib/env";
|
||||||
|
import { parseApiEvents } from "@/lib/events";
|
||||||
|
import type { AdminUser, ApiEvent, CalendarEvent, EventPayload } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every call to the API, made from the browser straight to the API's origin.
|
||||||
|
*
|
||||||
|
* `credentials: "include"` is mandatory, not a detail: the API is a different
|
||||||
|
* origin, so without it the browser leaves the session cookie behind and every
|
||||||
|
* call is anonymous. That fails as a plain 401, which reads like an expired
|
||||||
|
* login rather than a missing option.
|
||||||
|
*
|
||||||
|
* Callers must tell 401 from 403 (see `ApiError.status`): 401 means nobody is
|
||||||
|
* signed in and is the only status worth a trip to the login page; redirecting
|
||||||
|
* on 403 loops, because signing in succeeds and lands straight back on the
|
||||||
|
* refusal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
body: unknown;
|
||||||
|
constructor(status: number, body: { message?: string } & Record<string, unknown>, message?: string) {
|
||||||
|
super(message ?? body?.message ?? `API request failed with ${status}`);
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Network failure or a CORS refusal - the browser hides which. Status 0
|
||||||
|
// keeps it apart from anything the API itself answered.
|
||||||
|
throw new ApiError(0, {}, "Keine Verbindung zum Server.");
|
||||||
|
}
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(res.status, data);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventsPath = "/calendar/events";
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
/** Who is signed in, across all four apps. */
|
||||||
|
me: (): Promise<AdminUser> => request<AdminUser>("/admin/me"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One calendar's events, as the admin view: all statuses, deleted ones
|
||||||
|
* already dropped by the parser. A signed-in editor gets drafts; the same
|
||||||
|
* URL without a session returns only published events (or 403 for the
|
||||||
|
* internal calendars).
|
||||||
|
*/
|
||||||
|
getEvents: async (slug: CalendarSlug): Promise<CalendarEvent[]> =>
|
||||||
|
parseApiEvents(await request<ApiEvent[]>(`${eventsPath}/${slug}/json`)),
|
||||||
|
|
||||||
|
createEvent: async (payload: EventPayload): Promise<number> => {
|
||||||
|
const res = await request<{ eventId?: number }>(eventsPath, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
if (typeof res?.eventId !== "number") {
|
||||||
|
throw new ApiError(200, res ?? {}, "Der Server hat keine Termin-ID zurückgegeben.");
|
||||||
|
}
|
||||||
|
return res.eventId;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API answers 200 even when nothing was written, with
|
||||||
|
* `{message: "0 rows were updated"}` - so that body is a failure here.
|
||||||
|
*/
|
||||||
|
updateEvent: async (eventId: number, payload: EventPayload): Promise<void> => {
|
||||||
|
const res = await request<{ message?: string }>(`${eventsPath}/${eventId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res?.message === "0 rows were updated") {
|
||||||
|
throw new ApiError(200, res, "Der Termin wurde nicht gespeichert.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Moves the event to `payload.calendarId`. Only the calendar changes server-side. */
|
||||||
|
moveEvent: async (eventId: number, payload: EventPayload): Promise<void> => {
|
||||||
|
if (!calendarById(payload.calendarId)) {
|
||||||
|
throw new Error(`Unknown calendar ${payload.calendarId}`);
|
||||||
|
}
|
||||||
|
await request(`${eventsPath}/move/${eventId}`, { method: "PUT", body: JSON.stringify(payload) });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ready-made iCal URLs per calendar, with the shared password in place where
|
||||||
|
* one is needed. Calendars whose password isn't configured are missing.
|
||||||
|
*/
|
||||||
|
getSubscriptions: (): Promise<{ calendar: CalendarSlug; icalUrl: string }[]> =>
|
||||||
|
request(`${eventsPath}/subscriptions`),
|
||||||
|
|
||||||
|
/** A soft delete: the API appends a version with status DELETED. */
|
||||||
|
deleteEvent: async (eventId: number): Promise<void> => {
|
||||||
|
await request(`${eventsPath}/${eventId}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* The calendars, in one place.
|
||||||
|
*
|
||||||
|
* The Angular app kept this mapping three times - a switch in the admin page,
|
||||||
|
* an index-into-array in the move popup (which only worked because ids 3 and 4
|
||||||
|
* happen to sit in that order) and `calendarId === 5` checks in the API service
|
||||||
|
* and the filter - and the copies had drifted: the move popup could not move an
|
||||||
|
* event to Birthdays.
|
||||||
|
*
|
||||||
|
* Ids and slugs mirror `calendarNames` in the API's events.router.ts; the API
|
||||||
|
* has no endpoint that lists them (docs/api-wishlist.md), so a new calendar
|
||||||
|
* needs an entry here too.
|
||||||
|
*
|
||||||
|
* Class names are written out in full so Tailwind's source scan finds them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CalendarSlug = "public" | "members" | "choir" | "management" | "birthdays";
|
||||||
|
|
||||||
|
export interface Calendar {
|
||||||
|
id: number;
|
||||||
|
slug: CalendarSlug;
|
||||||
|
label: string;
|
||||||
|
/** Shown by default in the admin list. Birthdays would flood it. */
|
||||||
|
defaultVisible: boolean;
|
||||||
|
/**
|
||||||
|
* A recurrence every event in this calendar gets, whatever the form says.
|
||||||
|
* Replaces the old `calendarId === 5` special case in api.service.ts.
|
||||||
|
*/
|
||||||
|
forcedRepeatFrequency?: string;
|
||||||
|
dotClass: string;
|
||||||
|
badgeClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CALENDARS: readonly Calendar[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slug: "public",
|
||||||
|
label: "Öffentlich",
|
||||||
|
defaultVisible: true,
|
||||||
|
dotClass: "bg-sky-500",
|
||||||
|
badgeClass: "bg-sky-50 text-sky-800 ring-sky-200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
slug: "members",
|
||||||
|
label: "Mitglieder",
|
||||||
|
defaultVisible: true,
|
||||||
|
dotClass: "bg-emerald-500",
|
||||||
|
badgeClass: "bg-emerald-50 text-emerald-800 ring-emerald-200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
slug: "choir",
|
||||||
|
label: "Chor",
|
||||||
|
defaultVisible: true,
|
||||||
|
dotClass: "bg-violet-500",
|
||||||
|
badgeClass: "bg-violet-50 text-violet-800 ring-violet-200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
slug: "management",
|
||||||
|
label: "Management",
|
||||||
|
defaultVisible: true,
|
||||||
|
dotClass: "bg-amber-500",
|
||||||
|
badgeClass: "bg-amber-50 text-amber-900 ring-amber-200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
slug: "birthdays",
|
||||||
|
label: "Geburtstage",
|
||||||
|
defaultVisible: false,
|
||||||
|
forcedRepeatFrequency: "YEARLY",
|
||||||
|
dotClass: "bg-rose-500",
|
||||||
|
badgeClass: "bg-rose-50 text-rose-800 ring-rose-200",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const calendarById = (id: number): Calendar | undefined => CALENDARS.find((c) => c.id === id);
|
||||||
|
|
||||||
|
export const calendarBySlug = (slug: string): Calendar | undefined => CALENDARS.find((c) => c.slug === slug);
|
||||||
|
|
||||||
|
export const DEFAULT_VISIBLE_CALENDAR_IDS: readonly number[] = CALENDARS.filter((c) => c.defaultVisible).map((c) => c.id);
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
berlinDay,
|
||||||
|
daysBetween,
|
||||||
|
formatDateTime,
|
||||||
|
formatMonth,
|
||||||
|
fromWallTime,
|
||||||
|
monthKey,
|
||||||
|
nextFullHour,
|
||||||
|
nextOccurrence,
|
||||||
|
toWallTime,
|
||||||
|
wholeDayRange,
|
||||||
|
} from "@/lib/dates";
|
||||||
|
|
||||||
|
// vitest.config.ts runs this with TZ=America/New_York, so none of these can
|
||||||
|
// pass by accident on a machine that happens to be in Berlin.
|
||||||
|
|
||||||
|
describe("the test environment", () => {
|
||||||
|
it("is not in Berlin", () => {
|
||||||
|
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe("America/New_York");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fromWallTime / toWallTime", () => {
|
||||||
|
it("treats wall times as Berlin in summer (UTC+2) and winter (UTC+1)", () => {
|
||||||
|
expect(fromWallTime("2026-06-15", "19:00").toISOString()).toBe("2026-06-15T17:00:00.000Z");
|
||||||
|
expect(fromWallTime("2026-12-15", "19:00").toISOString()).toBe("2026-12-15T18:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips", () => {
|
||||||
|
expect(toWallTime(fromWallTime("2026-10-03", "07:05"))).toEqual({ date: "2026-10-03", time: "07:05" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an instant back as Berlin, including across midnight", () => {
|
||||||
|
// 23:30 UTC is already the next day in Berlin.
|
||||||
|
expect(toWallTime(new Date("2026-06-15T23:30:00Z"))).toEqual({ date: "2026-06-16", time: "01:30" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a time in the spring-forward gap to after it", () => {
|
||||||
|
// 02:30 does not exist on 2026-03-29 in Berlin; it becomes 03:30 CEST.
|
||||||
|
const instant = fromWallTime("2026-03-29", "02:30");
|
||||||
|
expect(instant.toISOString()).toBe("2026-03-29T01:30:00.000Z");
|
||||||
|
expect(toWallTime(instant)).toEqual({ date: "2026-03-29", time: "03:30" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a time that exists twice in autumn to the second (CET) one", () => {
|
||||||
|
expect(fromWallTime("2026-10-25", "02:30").toISOString()).toBe("2026-10-25T01:30:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("day arithmetic", () => {
|
||||||
|
it("adds days across month and year ends", () => {
|
||||||
|
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
|
||||||
|
expect(addDays("2026-03-01", -1)).toBe("2026-02-28");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not thrown off by the DST switch", () => {
|
||||||
|
expect(addDays("2026-03-28", 2)).toBe("2026-03-30");
|
||||||
|
expect(daysBetween("2026-10-24", "2026-10-26")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the Berlin day", () => {
|
||||||
|
expect(berlinDay(new Date("2026-06-15T22:30:00Z"))).toBe("2026-06-16");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wholeDayRange", () => {
|
||||||
|
it("spans 00:00 on the first day to 23:59 on the last, Berlin", () => {
|
||||||
|
const { start, end } = wholeDayRange("2026-07-04", "2026-07-05");
|
||||||
|
expect(toWallTime(start)).toEqual({ date: "2026-07-04", time: "00:00" });
|
||||||
|
expect(toWallTime(end)).toEqual({ date: "2026-07-05", time: "23:59" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nextFullHour", () => {
|
||||||
|
it("rounds up to the next hour in Berlin", () => {
|
||||||
|
expect(nextFullHour(new Date("2026-06-15T17:20:00Z"))).toEqual({ date: "2026-06-15", time: "20:00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls over midnight", () => {
|
||||||
|
expect(nextFullHour(new Date("2026-06-15T21:59:00Z"))).toEqual({ date: "2026-06-16", time: "00:00" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nextOccurrence", () => {
|
||||||
|
const now = new Date("2026-09-27T10:00:00Z");
|
||||||
|
|
||||||
|
it("leaves a future start alone", () => {
|
||||||
|
const start = fromWallTime("2026-10-01", "00:00");
|
||||||
|
expect(nextOccurrence(start, "YEARLY", now)).toBe(start);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a birthday to this year when it is still ahead", () => {
|
||||||
|
const start = fromWallTime("1990-11-03", "00:00");
|
||||||
|
expect(toWallTime(nextOccurrence(start, "YEARLY", now))).toEqual({ date: "2026-11-03", time: "00:00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a birthday to next year when it has passed", () => {
|
||||||
|
const start = fromWallTime("1990-02-10", "00:00");
|
||||||
|
expect(toWallTime(nextOccurrence(start, "YEARLY", now)).date).toBe("2027-02-10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a birthday today as current", () => {
|
||||||
|
const start = fromWallTime("1990-09-27", "00:00");
|
||||||
|
expect(toWallTime(nextOccurrence(start, "YEARLY", now)).date).toBe("2026-09-27");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts 29 February on 28 February in common years", () => {
|
||||||
|
const start = fromWallTime("2000-02-29", "00:00");
|
||||||
|
expect(toWallTime(nextOccurrence(start, "YEARLY", now)).date).toBe("2027-02-28");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles weekly and monthly series", () => {
|
||||||
|
const monday = fromWallTime("2026-01-05", "19:30"); // a Monday
|
||||||
|
expect(toWallTime(nextOccurrence(monday, "WEEKLY", now))).toEqual({ date: "2026-09-28", time: "19:30" });
|
||||||
|
const the31st = fromWallTime("2026-01-31", "18:00");
|
||||||
|
expect(toWallTime(nextOccurrence(the31st, "MONTHLY", now)).date).toBe("2026-09-30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unknown frequencies alone", () => {
|
||||||
|
const start = fromWallTime("2020-01-01", "00:00");
|
||||||
|
expect(nextOccurrence(start, "SOMETIMES", now)).toBe(start);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatting", () => {
|
||||||
|
it("formats German, in Berlin time", () => {
|
||||||
|
const instant = new Date("2026-10-03T17:00:00Z");
|
||||||
|
expect(formatDateTime(instant)).toBe("03.10.2026, 19:00");
|
||||||
|
expect(formatMonth(instant)).toBe("Oktober 2026");
|
||||||
|
expect(monthKey(new Date("2026-09-30T22:30:00Z"))).toBe("2026-10");
|
||||||
|
});
|
||||||
|
});
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
import { TZDate } from "@date-fns/tz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every wall-clock time in this app means Europe/Berlin, whatever time zone the
|
||||||
|
* editor's browser happens to be in.
|
||||||
|
*
|
||||||
|
* The Angular app used the browser's local time, which is only correct while
|
||||||
|
* the editor is in Germany: an editor abroad would enter "19:00" and save an
|
||||||
|
* event hours off. The API has no notion of time zones at all - it stores what
|
||||||
|
* it is sent and writes the iCal feed with the *server's* local `getHours()`,
|
||||||
|
* labelled `TZID=Europe/Berlin` (icalgenerator.service.ts) - so the frontend is
|
||||||
|
* the one place that can get this right, by always sending the UTC instant of a
|
||||||
|
* Berlin wall time.
|
||||||
|
*
|
||||||
|
* Dates travel through the form as "YYYY-MM-DD" and times as "HH:mm", exactly
|
||||||
|
* what <input type="date|time"> produce, so there is no Date object to mutate
|
||||||
|
* in place (the other Angular bug).
|
||||||
|
*
|
||||||
|
* DST, as @date-fns/tz resolves it and the tests pin:
|
||||||
|
* - a time that does not exist (02:30 on the last Sunday of March) moves
|
||||||
|
* forward to 03:30 CEST;
|
||||||
|
* - a time that exists twice (02:30 on the last Sunday of October) is the
|
||||||
|
* second one, CET.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TIME_ZONE = "Europe/Berlin";
|
||||||
|
const LOCALE = "de-DE";
|
||||||
|
|
||||||
|
export interface WallTime {
|
||||||
|
/** "YYYY-MM-DD" */
|
||||||
|
date: string;
|
||||||
|
/** "HH:mm" */
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number): string => n.toString().padStart(2, "0");
|
||||||
|
|
||||||
|
const splitDate = (date: string): [number, number, number] => {
|
||||||
|
const [y, m, d] = date.split("-").map(Number);
|
||||||
|
return [y, m, d];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Berlin wall time → instant. */
|
||||||
|
export const fromWallTime = (date: string, time: string): Date => {
|
||||||
|
const [y, m, d] = splitDate(date);
|
||||||
|
const [hh, mm] = time.split(":").map(Number);
|
||||||
|
return new Date(new TZDate(y, m - 1, d, hh, mm, TIME_ZONE).getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Instant → Berlin wall time. */
|
||||||
|
export const toWallTime = (instant: Date): WallTime => {
|
||||||
|
const t = new TZDate(instant.getTime(), TIME_ZONE);
|
||||||
|
return {
|
||||||
|
date: `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`,
|
||||||
|
time: `${pad(t.getHours())}:${pad(t.getMinutes())}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The Berlin calendar day an instant falls on, as "YYYY-MM-DD" (sorts as a string). */
|
||||||
|
export const berlinDay = (instant: Date): string => toWallTime(instant).date;
|
||||||
|
|
||||||
|
/** Calendar arithmetic on "YYYY-MM-DD", independent of any time zone. */
|
||||||
|
export const addDays = (date: string, days: number): string => {
|
||||||
|
const [y, m, d] = splitDate(date);
|
||||||
|
const u = new Date(Date.UTC(y, m - 1, d + days));
|
||||||
|
return `${u.getUTCFullYear()}-${pad(u.getUTCMonth() + 1)}-${pad(u.getUTCDate())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whole days from `a` to `b` ("YYYY-MM-DD"); negative when b is earlier. */
|
||||||
|
export const daysBetween = (a: string, b: string): number => {
|
||||||
|
const [ay, am, ad] = splitDate(a);
|
||||||
|
const [by, bm, bd] = splitDate(b);
|
||||||
|
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-day event's stored range: 00:00 on the first day to 23:59 on the
|
||||||
|
* last, both Berlin.
|
||||||
|
*
|
||||||
|
* The end is the *inclusive* last day because that is what the iCal generator
|
||||||
|
* assumes - it adds one day to a whole-day end (`formatDate(…, true, true)`).
|
||||||
|
* 23:59 rather than 00:00 keeps the event "upcoming" for the whole of its last
|
||||||
|
* day in the list filter.
|
||||||
|
*/
|
||||||
|
export const wholeDayRange = (startDate: string, endDate: string): { start: Date; end: Date } => ({
|
||||||
|
start: fromWallTime(startDate, "00:00"),
|
||||||
|
end: fromWallTime(endDate, "23:59"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The next full hour after `now`, as Berlin wall time. */
|
||||||
|
export const nextFullHour = (now: Date): WallTime => {
|
||||||
|
const { date, time } = toWallTime(now);
|
||||||
|
const hour = Number(time.slice(0, 2)) + 1;
|
||||||
|
return hour === 24 ? { date: addDays(date, 1), time: "00:00" } : { date, time: `${pad(hour)}:00` };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first occurrence of a repeating event that starts today (Berlin) or
|
||||||
|
* later, keeping its wall-clock time. Non-repeating events, and frequencies
|
||||||
|
* the API may hold but this app does not know, return `start` unchanged.
|
||||||
|
*
|
||||||
|
* Only YEARLY is ever written by this app (birthdays), but the API accepts any
|
||||||
|
* `FREQ` string, so older data may hold others.
|
||||||
|
*
|
||||||
|
* A yearly event on 29 February falls on 28 February in other years - the
|
||||||
|
* choice a birthday list wants, though RFC 5545 would skip those years.
|
||||||
|
*/
|
||||||
|
export const nextOccurrence = (start: Date, repeatFrequency: string, now: Date): Date => {
|
||||||
|
const today = berlinDay(now);
|
||||||
|
const { date, time } = toWallTime(start);
|
||||||
|
if (date >= today) {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
const [, m, d] = splitDate(date);
|
||||||
|
|
||||||
|
switch (repeatFrequency) {
|
||||||
|
case "YEARLY": {
|
||||||
|
const year = Number(today.slice(0, 4));
|
||||||
|
const candidate = (yy: number) => {
|
||||||
|
const lastDay = new Date(Date.UTC(yy, m, 0)).getUTCDate();
|
||||||
|
return `${yy}-${pad(m)}-${pad(Math.min(d, lastDay))}`;
|
||||||
|
};
|
||||||
|
const thisYear = candidate(year);
|
||||||
|
return fromWallTime(thisYear >= today ? thisYear : candidate(year + 1), time);
|
||||||
|
}
|
||||||
|
case "MONTHLY": {
|
||||||
|
const [ty, tm] = splitDate(today);
|
||||||
|
const candidate = (yy: number, mm: number) => {
|
||||||
|
const lastDay = new Date(Date.UTC(yy, mm, 0)).getUTCDate();
|
||||||
|
return `${yy}-${pad(mm)}-${pad(Math.min(d, lastDay))}`;
|
||||||
|
};
|
||||||
|
const thisMonth = candidate(ty, tm);
|
||||||
|
if (thisMonth >= today) {
|
||||||
|
return fromWallTime(thisMonth, time);
|
||||||
|
}
|
||||||
|
return fromWallTime(tm === 12 ? candidate(ty + 1, 1) : candidate(ty, tm + 1), time);
|
||||||
|
}
|
||||||
|
case "WEEKLY":
|
||||||
|
case "DAILY": {
|
||||||
|
const step = repeatFrequency === "WEEKLY" ? 7 : 1;
|
||||||
|
const steps = Math.ceil(daysBetween(date, today) / step);
|
||||||
|
return fromWallTime(addDays(date, steps * step), time);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayFormat = new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
timeZone: TIME_ZONE,
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const timeFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, hour: "2-digit", minute: "2-digit" });
|
||||||
|
const dateTimeFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, dateStyle: "medium", timeStyle: "short" });
|
||||||
|
const monthFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
/** "Sa., 3. Okt. 2026" */
|
||||||
|
export const formatDay = (instant: Date): string => dayFormat.format(instant);
|
||||||
|
/** "19:00" */
|
||||||
|
export const formatTime = (instant: Date): string => timeFormat.format(instant);
|
||||||
|
/** "03.10.2026, 19:00" */
|
||||||
|
export const formatDateTime = (instant: Date): string => dateTimeFormat.format(instant);
|
||||||
|
/** "Oktober 2026" */
|
||||||
|
export const formatMonth = (instant: Date): string => monthFormat.format(instant);
|
||||||
|
/** "2026-10" - a sortable month key. */
|
||||||
|
export const monthKey = (instant: Date): string => berlinDay(instant).slice(0, 7);
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* The `NEXT_PUBLIC_*` values this app needs, read once and validated here
|
||||||
|
* rather than defaulted at each use site. Mirrors `lib/env.ts` in
|
||||||
|
* nachklang-feedback, nachklang-admin and nachklang-tickets.
|
||||||
|
*
|
||||||
|
* Throwing is the whole point. Next inlines `process.env.NEXT_PUBLIC_*` at
|
||||||
|
* **build** time, so a value missing when Plesk runs `npm run build` is baked
|
||||||
|
* into the bundle and cannot be fixed by restarting the app. A fallback to
|
||||||
|
* localhost would ship an admin area that sends every signed-out user to
|
||||||
|
* `http://localhost:3002` - no error, no log, just a dead end. A failed build is
|
||||||
|
* the better outcome, on the machine that can still do something about it.
|
||||||
|
*
|
||||||
|
* Referenced by their full literal name because that is what the inlining
|
||||||
|
* matches - no dynamic lookup, no destructuring of `process.env`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function requireEnv(name: string, value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is not set. Copy .env.local.example and fill it in before building.`);
|
||||||
|
}
|
||||||
|
// URL.origin never carries a trailing slash, and neither should anything we concatenate onto.
|
||||||
|
return value.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_BASE_URL = requireEnv("NEXT_PUBLIC_API_BASE_URL", process.env.NEXT_PUBLIC_API_BASE_URL);
|
||||||
|
|
||||||
|
export const ADMIN_APP_URL = requireEnv("NEXT_PUBLIC_ADMIN_APP_URL", process.env.NEXT_PUBLIC_ADMIN_APP_URL);
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
|
||||||
|
import { fromWallTime, toWallTime } from "@/lib/dates";
|
||||||
|
import { dateBlock, describeWhen, isPast, occurrence, repeatLabel, todayMarkerIndex } from "@/lib/event-display";
|
||||||
|
import { makeEvent } from "@/lib/test-fixtures";
|
||||||
|
|
||||||
|
const now = new Date("2026-09-27T10:00:00Z");
|
||||||
|
|
||||||
|
describe("dateBlock", () => {
|
||||||
|
it("gives weekday, day and month in German, Berlin time", () => {
|
||||||
|
// 23:30 UTC on 2 Oct is already Saturday 3 Oct in Berlin.
|
||||||
|
expect(dateBlock(new Date("2026-10-02T23:30:00Z"))).toEqual({ weekday: "Sa", day: "3", month: "Okt" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("describeWhen", () => {
|
||||||
|
it("shows a same-day time range", () => {
|
||||||
|
expect(describeWhen(makeEvent(), now)).toBe("19:00–21:00 Uhr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a multi-day timed event with both dates", () => {
|
||||||
|
const event = makeEvent({ start: fromWallTime("2026-10-03", "19:00"), end: fromWallTime("2026-10-05", "12:00") });
|
||||||
|
expect(describeWhen(event, now)).toBe("3. Okt., 19:00 – 5. Okt., 12:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows whole-day events, with the range when they span days", () => {
|
||||||
|
const oneDay = makeEvent({ wholeDay: true, start: fromWallTime("2026-10-03", "00:00"), end: fromWallTime("2026-10-03", "23:59") });
|
||||||
|
const weekend = makeEvent({ wholeDay: true, start: fromWallTime("2026-10-17", "00:00"), end: fromWallTime("2026-10-19", "23:59") });
|
||||||
|
expect(describeWhen(oneDay, now)).toBe("Ganztägig");
|
||||||
|
expect(describeWhen(weekend, now)).toBe("Ganztägig · 17.–19. Okt.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("occurrence", () => {
|
||||||
|
it("moves a birthday's start and end to this year's date together", () => {
|
||||||
|
const birthday = makeEvent({
|
||||||
|
wholeDay: true,
|
||||||
|
repeatFrequency: "YEARLY",
|
||||||
|
start: fromWallTime("1994-10-03", "00:00"),
|
||||||
|
end: fromWallTime("1994-10-03", "23:59"),
|
||||||
|
});
|
||||||
|
const { start, end } = occurrence(birthday, now);
|
||||||
|
expect(toWallTime(start)).toEqual({ date: "2026-10-03", time: "00:00" });
|
||||||
|
expect(toWallTime(end)).toEqual({ date: "2026-10-03", time: "23:59" });
|
||||||
|
expect(describeWhen(birthday, now)).toBe("Ganztägig");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isPast", () => {
|
||||||
|
it("is true only for non-repeating events that ended before today", () => {
|
||||||
|
const old = { start: fromWallTime("2026-07-18", "15:00"), end: fromWallTime("2026-07-18", "22:00") };
|
||||||
|
expect(isPast(makeEvent(old), now)).toBe(true);
|
||||||
|
expect(isPast(makeEvent({ ...old, repeatFrequency: "YEARLY" }), now)).toBe(false);
|
||||||
|
expect(isPast(makeEvent(), now)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("todayMarkerIndex", () => {
|
||||||
|
const past = makeEvent({ eventId: 1, start: fromWallTime("2026-07-18", "15:00"), end: fromWallTime("2026-07-18", "22:00") });
|
||||||
|
const today = makeEvent({ eventId: 2, start: fromWallTime("2026-09-27", "08:00"), end: fromWallTime("2026-09-27", "09:00") });
|
||||||
|
const later = makeEvent({ eventId: 3 });
|
||||||
|
|
||||||
|
it("sits before the first event not yet over, ascending", () => {
|
||||||
|
expect(todayMarkerIndex([past, today, later], "start_asc", now)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sits before the first past event, descending", () => {
|
||||||
|
expect(todayMarkerIndex([later, today, past], "start_desc", now)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes to the end or the start when everything is on one side", () => {
|
||||||
|
expect(todayMarkerIndex([past], "start_asc", now)).toBe(1);
|
||||||
|
expect(todayMarkerIndex([later], "start_asc", now)).toBe(0);
|
||||||
|
expect(todayMarkerIndex([later], "start_desc", now)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no place in a list sorted by creation, or an empty one", () => {
|
||||||
|
expect(todayMarkerIndex([past, later], "created_asc", now)).toBeNull();
|
||||||
|
expect(todayMarkerIndex([], "start_asc", now)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("repeatLabel", () => {
|
||||||
|
it("translates known frequencies and passes unknown ones through", () => {
|
||||||
|
expect(repeatLabel("")).toBe("");
|
||||||
|
expect(repeatLabel("YEARLY")).toBe("jährlich");
|
||||||
|
expect(repeatLabel("HOURLY")).toBe("hourly");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("describeApiError", () => {
|
||||||
|
it("explains each kind of failure in German", () => {
|
||||||
|
expect(describeApiError(new ApiError(0, {}))).toMatch(/Keine Verbindung/);
|
||||||
|
expect(describeApiError(new ApiError(403, {}))).toMatch(/Berechtigung/);
|
||||||
|
expect(describeApiError(new ApiError(500, {}))).toMatch(/\(500\)/);
|
||||||
|
expect(describeApiError(new ApiError(200, {}, "Der Termin wurde nicht gespeichert."))).toBe(
|
||||||
|
"Der Termin wurde nicht gespeichert.",
|
||||||
|
);
|
||||||
|
expect(describeApiError(new Error("x"))).toMatch(/Unerwarteter Fehler/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises only 401 as an expired session", () => {
|
||||||
|
expect(isSessionExpired(new ApiError(401, {}))).toBe(true);
|
||||||
|
expect(isSessionExpired(new ApiError(403, {}))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { berlinDay, formatTime, TIME_ZONE } from "@/lib/dates";
|
||||||
|
import { effectiveStart, isRecurring, type Sort } from "@/lib/filter";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How an event reads in the list. Pure, so the German wording is testable.
|
||||||
|
* All of it is about the *effective* occurrence: a birthday shows this year's
|
||||||
|
* date, not the year of birth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LOCALE = "de-DE";
|
||||||
|
const part = (options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, ...options });
|
||||||
|
const weekdayFormat = part({ weekday: "short" });
|
||||||
|
const dayFormat = part({ day: "numeric" });
|
||||||
|
const monthFormat = part({ month: "short" });
|
||||||
|
const dayMonthFormat = part({ day: "numeric", month: "short" });
|
||||||
|
|
||||||
|
export interface DateBlock {
|
||||||
|
/** "Sa" */
|
||||||
|
weekday: string;
|
||||||
|
/** "3" */
|
||||||
|
day: string;
|
||||||
|
/** "Okt" */
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dateBlock = (instant: Date): DateBlock => ({
|
||||||
|
weekday: weekdayFormat.format(instant),
|
||||||
|
day: dayFormat.format(instant),
|
||||||
|
month: monthFormat.format(instant),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The occurrence the list shows: start moved to the next repeat, end moved by the same amount. */
|
||||||
|
export const occurrence = (event: CalendarEvent, now: Date): { start: Date; end: Date } => {
|
||||||
|
const start = effectiveStart(event, now);
|
||||||
|
const shift = start.getTime() - event.start.getTime();
|
||||||
|
return { start, end: new Date(event.end.getTime() + shift) };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "19:00–21:00 Uhr", "Ganztägig", "Ganztägig · 17.–19. Okt.",
|
||||||
|
* "3. Okt., 19:00 – 5. Okt., 12:00".
|
||||||
|
*/
|
||||||
|
export const describeWhen = (event: CalendarEvent, now: Date): string => {
|
||||||
|
const { start, end } = occurrence(event, now);
|
||||||
|
const sameDay = berlinDay(start) === berlinDay(end);
|
||||||
|
|
||||||
|
if (event.wholeDay) {
|
||||||
|
return sameDay ? "Ganztägig" : `Ganztägig · ${dayMonthFormat.formatRange(start, end)}`;
|
||||||
|
}
|
||||||
|
if (sameDay) {
|
||||||
|
return `${formatTime(start)}–${formatTime(end)} Uhr`;
|
||||||
|
}
|
||||||
|
return `${dayMonthFormat.format(start)}, ${formatTime(start)} – ${dayMonthFormat.format(end)}, ${formatTime(end)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Over before today (Berlin). Repeating events never are. */
|
||||||
|
export const isPast = (event: CalendarEvent, now: Date): boolean =>
|
||||||
|
!isRecurring(event) && berlinDay(event.end) < berlinDay(now);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where "today" falls in a list sorted by start: the index of the event the
|
||||||
|
* "Heute" marker goes in front of, or `events.length` for after the last one.
|
||||||
|
* Ascending, that is the first event not yet over; descending, the first one
|
||||||
|
* that is. Null for a list that isn't ordered by start, or an empty one.
|
||||||
|
*/
|
||||||
|
export const todayMarkerIndex = (events: readonly CalendarEvent[], sort: Sort, now: Date): number | null => {
|
||||||
|
if (events.length === 0 || sort.startsWith("created")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lookingFor = sort === "start_desc";
|
||||||
|
const index = events.findIndex((e) => isPast(e, now) === lookingFor);
|
||||||
|
return index === -1 ? events.length : index;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The badge for a repeat frequency; "" for none. Unknown values are shown as they are. */
|
||||||
|
export const repeatLabel = (repeatFrequency: string): string => {
|
||||||
|
switch (repeatFrequency) {
|
||||||
|
case "":
|
||||||
|
return "";
|
||||||
|
case "YEARLY":
|
||||||
|
return "jährlich";
|
||||||
|
case "MONTHLY":
|
||||||
|
return "monatlich";
|
||||||
|
case "WEEKLY":
|
||||||
|
return "wöchentlich";
|
||||||
|
case "DAILY":
|
||||||
|
return "täglich";
|
||||||
|
default:
|
||||||
|
return repeatFrequency.toLowerCase();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { toWallTime } from "@/lib/dates";
|
||||||
|
import {
|
||||||
|
contentChanged,
|
||||||
|
duplicateForm,
|
||||||
|
eventFromPayload,
|
||||||
|
formFromEvent,
|
||||||
|
isFormDirty,
|
||||||
|
newEventForm,
|
||||||
|
normaliseUrl,
|
||||||
|
payloadFromEvent,
|
||||||
|
setCalendar,
|
||||||
|
setStart,
|
||||||
|
toPayload,
|
||||||
|
validateEventForm,
|
||||||
|
type EventForm,
|
||||||
|
} from "@/lib/event-form";
|
||||||
|
import { makeEvent } from "@/lib/test-fixtures";
|
||||||
|
|
||||||
|
const now = new Date("2026-09-27T10:20:00Z"); // 12:20 in Berlin
|
||||||
|
|
||||||
|
const validForm = (overrides: Partial<EventForm> = {}): EventForm => ({
|
||||||
|
...formFromEvent(makeEvent()),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("newEventForm", () => {
|
||||||
|
it("starts at the next full Berlin hour, lasts two hours, and is a draft", () => {
|
||||||
|
const form = newEventForm(now, 2);
|
||||||
|
expect(form).toMatchObject({
|
||||||
|
calendarId: 2,
|
||||||
|
startDate: "2026-09-27",
|
||||||
|
startTime: "13:00",
|
||||||
|
endDate: "2026-09-27",
|
||||||
|
endTime: "15:00",
|
||||||
|
status: "DRAFT",
|
||||||
|
repeatFrequency: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes a new birthday yearly", () => {
|
||||||
|
expect(newEventForm(now, 5).repeatFrequency).toBe("YEARLY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the calendar unchosen when none is given", () => {
|
||||||
|
expect(validateEventForm({ ...newEventForm(now), name: "x" }).calendarId).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("duplicateForm", () => {
|
||||||
|
it("copies the event as a new draft with a marked title", () => {
|
||||||
|
const form = duplicateForm(makeEvent({ name: "Probe", status: "PUBLIC" }));
|
||||||
|
expect(form.name).toBe("Probe (Kopie)");
|
||||||
|
expect(form.status).toBe("DRAFT");
|
||||||
|
expect(form.startTime).toBe("19:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setStart", () => {
|
||||||
|
it("moves the end along, keeping a timed event's duration", () => {
|
||||||
|
const form = setStart(validForm(), "2026-10-10", "18:30");
|
||||||
|
expect(form).toMatchObject({ startDate: "2026-10-10", startTime: "18:30", endDate: "2026-10-10", endTime: "20:30" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the duration in real time across the DST switch", () => {
|
||||||
|
// 2 h starting 01:00 on 25 Oct: 01:00 CEST + 2 h = 02:00 CET.
|
||||||
|
const form = setStart(validForm(), "2026-10-25", "01:00");
|
||||||
|
expect(form).toMatchObject({ endDate: "2026-10-25", endTime: "02:00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a whole-day event's length in days", () => {
|
||||||
|
const form = setStart(
|
||||||
|
validForm({ wholeDay: true, startDate: "2026-07-04", endDate: "2026-07-06" }),
|
||||||
|
"2026-08-01",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
expect(form).toMatchObject({ startDate: "2026-08-01", endDate: "2026-08-03" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not guess an end while the start is being cleared", () => {
|
||||||
|
const form = setStart(validForm(), "", "19:00");
|
||||||
|
expect(form).toMatchObject({ startDate: "", endDate: "2026-10-03" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setCalendar", () => {
|
||||||
|
it("makes an event yearly when it moves into Birthdays", () => {
|
||||||
|
expect(setCalendar(validForm(), 5).repeatFrequency).toBe("YEARLY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the forced recurrence when it moves out of Birthdays", () => {
|
||||||
|
expect(setCalendar(validForm({ calendarId: 5, repeatFrequency: "YEARLY" }), 2).repeatFrequency).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a recurrence that other calendars' events already had", () => {
|
||||||
|
expect(setCalendar(validForm({ calendarId: 2, repeatFrequency: "WEEKLY" }), 4).repeatFrequency).toBe("WEEKLY");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normaliseUrl", () => {
|
||||||
|
it.each([
|
||||||
|
["", ""],
|
||||||
|
[" ", ""],
|
||||||
|
["nachklang.art", "https://nachklang.art"],
|
||||||
|
["www.nachklang.art/konzert?x=1", "https://www.nachklang.art/konzert?x=1"],
|
||||||
|
["http://example.org", "http://example.org"],
|
||||||
|
["https://münchen.de", "https://münchen.de"],
|
||||||
|
])("accepts %j as %j", (raw, value) => {
|
||||||
|
expect(normaliseUrl(raw)).toEqual({ ok: true, value });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["nachklang", "javascript:alert(1)", "mailto:info@nachklang.art", "https://", "ht tp://x.de"])(
|
||||||
|
"rejects %j",
|
||||||
|
(raw) => {
|
||||||
|
expect(normaliseUrl(raw).ok).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateEventForm", () => {
|
||||||
|
it("accepts a complete event", () => {
|
||||||
|
expect(validateEventForm(validForm())).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a title and a calendar", () => {
|
||||||
|
const errors = validateEventForm(validForm({ name: " ", calendarId: 99 }));
|
||||||
|
expect(Object.keys(errors).sort()).toEqual(["calendarId", "name"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires times only for timed events", () => {
|
||||||
|
expect(validateEventForm(validForm({ startTime: "" })).startTime).toBeDefined();
|
||||||
|
expect(validateEventForm(validForm({ wholeDay: true, startTime: "", endTime: "" }))).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an end before the start, on the end field", () => {
|
||||||
|
expect(validateEventForm(validForm({ endTime: "18:00" })).endDate).toBe("Das Ende liegt vor dem Beginn.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a one-day whole-day event whose hidden times would be backwards", () => {
|
||||||
|
expect(validateEventForm(validForm({ wholeDay: true, startTime: "22:00", endTime: "08:00" }))).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags an unusable URL", () => {
|
||||||
|
expect(validateEventForm(validForm({ url: "kein link" })).url).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toPayload", () => {
|
||||||
|
it("sends UTC instants of the Berlin wall times, and every field", () => {
|
||||||
|
const payload = toPayload(validForm({ name: " Konzert ", url: "nachklang.art" }));
|
||||||
|
expect(payload).toEqual({
|
||||||
|
calendarId: 1,
|
||||||
|
name: "Konzert",
|
||||||
|
description: "",
|
||||||
|
startDateTime: "2026-10-03T17:00:00.000Z",
|
||||||
|
endDateTime: "2026-10-03T19:00:00.000Z",
|
||||||
|
location: "Karlsruhe",
|
||||||
|
url: "https://nachklang.art",
|
||||||
|
wholeDay: false,
|
||||||
|
repeatFrequency: "",
|
||||||
|
status: "PUBLIC",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves whole-day events as 00:00-23:59 Berlin, ignoring stale times", () => {
|
||||||
|
const payload = toPayload(
|
||||||
|
validForm({ wholeDay: true, startDate: "2026-12-24", endDate: "2026-12-26", startTime: "19:00", endTime: "21:00" }),
|
||||||
|
);
|
||||||
|
expect(toWallTime(new Date(payload.startDateTime))).toEqual({ date: "2026-12-24", time: "00:00" });
|
||||||
|
expect(toWallTime(new Date(payload.endDateTime))).toEqual({ date: "2026-12-26", time: "23:59" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the birthday recurrence and any other status", () => {
|
||||||
|
const payload = toPayload(validForm({ calendarId: 5, repeatFrequency: "YEARLY", status: "PRIVATE" }));
|
||||||
|
expect(payload).toMatchObject({ repeatFrequency: "YEARLY", status: "PRIVATE" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("payloadFromEvent", () => {
|
||||||
|
it("resends the full event with only the status changed", () => {
|
||||||
|
const event = makeEvent({ status: "DRAFT", repeatFrequency: "YEARLY", calendarId: 5 });
|
||||||
|
expect(payloadFromEvent(event, "PUBLIC")).toMatchObject({
|
||||||
|
status: "PUBLIC",
|
||||||
|
repeatFrequency: "YEARLY",
|
||||||
|
calendarId: 5,
|
||||||
|
startDateTime: event.start.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isFormDirty", () => {
|
||||||
|
it("notices any change and nothing else", () => {
|
||||||
|
const form = validForm();
|
||||||
|
expect(isFormDirty(form, { ...form })).toBe(false);
|
||||||
|
expect(isFormDirty(form, { ...form, location: "Mannheim" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("contentChanged", () => {
|
||||||
|
it("ignores a change of calendar alone", () => {
|
||||||
|
const form = validForm();
|
||||||
|
expect(contentChanged(form, { ...form, calendarId: 4 })).toBe(false);
|
||||||
|
expect(contentChanged(form, { ...form, calendarId: 4, name: "Neu" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("eventFromPayload", () => {
|
||||||
|
const now = new Date("2026-09-28T12:00:00Z");
|
||||||
|
|
||||||
|
it("keeps who created an edited event and records who changed it", () => {
|
||||||
|
const previous = makeEvent({ createdBy: "Anna", uuid: "u-1" });
|
||||||
|
const saved = eventFromPayload(1, toPayload(validForm({ name: "Neu" })), "Ben", now, previous);
|
||||||
|
expect(saved).toMatchObject({ eventId: 1, uuid: "u-1", name: "Neu", createdBy: "Anna", lastModifiedBy: "Ben" });
|
||||||
|
expect(saved.created).toBe(previous.created);
|
||||||
|
expect(saved.start.toISOString()).toBe("2026-10-03T17:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attributes a new event to the author", () => {
|
||||||
|
const saved = eventFromPayload(42, toPayload(validForm()), "Ben", now);
|
||||||
|
expect(saved).toMatchObject({ eventId: 42, createdBy: "Ben", created: now });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { calendarById } from "@/lib/calendars";
|
||||||
|
import { addDays, daysBetween, fromWallTime, nextFullHour, toWallTime, wholeDayRange } from "@/lib/dates";
|
||||||
|
import type { CalendarEvent, EventPayload, EventStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The edit sheet's state, and every rule about it that is not rendering.
|
||||||
|
*
|
||||||
|
* Kept as plain strings in the shape the inputs produce, so nothing here ever
|
||||||
|
* mutates a Date - the Angular form edited `event.startDateTime` in place with
|
||||||
|
* setFullYear/setHours, which is how a cancelled edit could still leak into the
|
||||||
|
* list.
|
||||||
|
*/
|
||||||
|
export interface EventForm {
|
||||||
|
calendarId: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
location: string;
|
||||||
|
url: string;
|
||||||
|
wholeDay: boolean;
|
||||||
|
/** Berlin wall time. For whole-day events the times are ignored. */
|
||||||
|
startDate: string;
|
||||||
|
startTime: string;
|
||||||
|
endDate: string;
|
||||||
|
endTime: string;
|
||||||
|
status: EventStatus;
|
||||||
|
repeatFrequency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventFormField = keyof EventForm;
|
||||||
|
export type EventFormErrors = Partial<Record<EventFormField, string>>;
|
||||||
|
|
||||||
|
const DEFAULT_DURATION_HOURS = 2;
|
||||||
|
|
||||||
|
/** Applies the calendar's forced recurrence, if it has one. */
|
||||||
|
const withCalendarRules = (form: EventForm): EventForm => {
|
||||||
|
const forced = calendarById(form.calendarId)?.forcedRepeatFrequency;
|
||||||
|
return forced ? { ...form, repeatFrequency: forced } : form;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new event: the next full hour, two hours long, as a draft. `calendarId`
|
||||||
|
* is 0 ("none chosen") when the caller cannot tell which calendar is meant.
|
||||||
|
*/
|
||||||
|
export const newEventForm = (now: Date, calendarId = 0): EventForm => {
|
||||||
|
const start = nextFullHour(now);
|
||||||
|
const end = toWallTime(new Date(fromWallTime(start.date, start.time).getTime() + DEFAULT_DURATION_HOURS * 3_600_000));
|
||||||
|
return withCalendarRules({
|
||||||
|
calendarId,
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
location: "",
|
||||||
|
url: "",
|
||||||
|
wholeDay: false,
|
||||||
|
startDate: start.date,
|
||||||
|
startTime: start.time,
|
||||||
|
endDate: end.date,
|
||||||
|
endTime: end.time,
|
||||||
|
status: "DRAFT",
|
||||||
|
repeatFrequency: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formFromEvent = (event: CalendarEvent): EventForm => {
|
||||||
|
const start = toWallTime(event.start);
|
||||||
|
const end = toWallTime(event.end);
|
||||||
|
return {
|
||||||
|
calendarId: event.calendarId,
|
||||||
|
name: event.name,
|
||||||
|
description: event.description,
|
||||||
|
location: event.location,
|
||||||
|
url: event.url,
|
||||||
|
wholeDay: event.wholeDay,
|
||||||
|
startDate: start.date,
|
||||||
|
startTime: start.time,
|
||||||
|
endDate: end.date,
|
||||||
|
endTime: end.time,
|
||||||
|
status: event.status,
|
||||||
|
repeatFrequency: event.repeatFrequency,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A copy of an existing event as a new, unsaved draft. */
|
||||||
|
export const duplicateForm = (event: CalendarEvent): EventForm => ({
|
||||||
|
...formFromEvent(event),
|
||||||
|
name: `${event.name} (Kopie)`,
|
||||||
|
status: "DRAFT",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changing the start moves the end with it, keeping the duration - so
|
||||||
|
* shifting a rehearsal to another day does not leave its end behind. (The
|
||||||
|
* Angular form only pulled the end up when it would otherwise precede the
|
||||||
|
* start.) Whole-day events keep their length in days, timed ones in minutes.
|
||||||
|
*/
|
||||||
|
export const setStart = (form: EventForm, startDate: string, startTime: string): EventForm => {
|
||||||
|
if (!startDate || !form.startDate || !form.endDate) {
|
||||||
|
return { ...form, startDate, startTime };
|
||||||
|
}
|
||||||
|
if (form.wholeDay) {
|
||||||
|
const days = daysBetween(form.startDate, form.endDate);
|
||||||
|
return { ...form, startDate, startTime, endDate: addDays(startDate, Math.max(days, 0)) };
|
||||||
|
}
|
||||||
|
if (!startTime || !form.startTime || !form.endTime) {
|
||||||
|
return { ...form, startDate, startTime };
|
||||||
|
}
|
||||||
|
const duration =
|
||||||
|
fromWallTime(form.endDate, form.endTime).getTime() - fromWallTime(form.startDate, form.startTime).getTime();
|
||||||
|
const end = toWallTime(new Date(fromWallTime(startDate, startTime).getTime() + Math.max(duration, 0)));
|
||||||
|
return { ...form, startDate, startTime, endDate: end.date, endTime: end.time };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaving a calendar that forces a recurrence (Birthdays) also drops it -
|
||||||
|
* otherwise moving a birthday into another calendar would quietly turn it into
|
||||||
|
* a yearly event there. A recurrence the event had for any other reason stays.
|
||||||
|
*/
|
||||||
|
export const setCalendar = (form: EventForm, calendarId: number): EventForm => {
|
||||||
|
const leaving = calendarById(form.calendarId)?.forcedRepeatFrequency;
|
||||||
|
const base = leaving && form.repeatFrequency === leaving ? { ...form, repeatFrequency: "" } : form;
|
||||||
|
return withCalendarRules({ ...base, calendarId });
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UrlCheck = { ok: true; value: string } | { ok: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts what people actually type: "nachklang.art/konzert" becomes
|
||||||
|
* "https://nachklang.art/konzert". Only http(s) URLs with a dotted host pass.
|
||||||
|
*
|
||||||
|
* Returns the normalised value rather than writing it back - the Angular
|
||||||
|
* version rewrote the field as a side effect of validating it, which the user
|
||||||
|
* could see happen on a failed save.
|
||||||
|
*/
|
||||||
|
export const normaliseUrl = (raw: string): UrlCheck => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
return { ok: true, value: "" };
|
||||||
|
}
|
||||||
|
const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||||
|
try {
|
||||||
|
const url = new URL(withScheme);
|
||||||
|
if ((url.protocol !== "https:" && url.protocol !== "http:") || !url.hostname.includes(".")) {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: withScheme };
|
||||||
|
} catch {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const range = (form: EventForm): { start: Date; end: Date } =>
|
||||||
|
form.wholeDay
|
||||||
|
? wholeDayRange(form.startDate, form.endDate)
|
||||||
|
: { start: fromWallTime(form.startDate, form.startTime), end: fromWallTime(form.endDate, form.endTime) };
|
||||||
|
|
||||||
|
/** Field errors in German, keyed by the field they belong next to. Empty = valid. */
|
||||||
|
export const validateEventForm = (form: EventForm): EventFormErrors => {
|
||||||
|
const errors: EventFormErrors = {};
|
||||||
|
|
||||||
|
if (!calendarById(form.calendarId)) {
|
||||||
|
errors.calendarId = "Bitte einen Kalender wählen.";
|
||||||
|
}
|
||||||
|
if (form.name.trim() === "") {
|
||||||
|
errors.name = "Bitte einen Titel angeben.";
|
||||||
|
}
|
||||||
|
if (!form.startDate) {
|
||||||
|
errors.startDate = "Bitte ein Startdatum angeben.";
|
||||||
|
}
|
||||||
|
if (!form.endDate) {
|
||||||
|
errors.endDate = "Bitte ein Enddatum angeben.";
|
||||||
|
}
|
||||||
|
if (!form.wholeDay) {
|
||||||
|
if (!form.startTime) {
|
||||||
|
errors.startTime = "Bitte eine Startzeit angeben.";
|
||||||
|
}
|
||||||
|
if (!form.endTime) {
|
||||||
|
errors.endTime = "Bitte eine Endzeit angeben.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!errors.startDate && !errors.endDate && !errors.startTime && !errors.endTime) {
|
||||||
|
const { start, end } = range(form);
|
||||||
|
if (end.getTime() < start.getTime()) {
|
||||||
|
errors.endDate = "Das Ende liegt vor dem Beginn.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!normaliseUrl(form.url).ok) {
|
||||||
|
errors.url = "Das ist keine gültige Web-Adresse.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full body for create/update/move. Call only on a form that validated.
|
||||||
|
*
|
||||||
|
* Whole-day events are saved as 00:00-23:59 Berlin whatever the hidden time
|
||||||
|
* inputs held; the Angular app kept the stale times, so the same "whole-day"
|
||||||
|
* event could end at any hour.
|
||||||
|
*/
|
||||||
|
export const toPayload = (form: EventForm): EventPayload => {
|
||||||
|
const { start, end } = range(form);
|
||||||
|
const url = normaliseUrl(form.url);
|
||||||
|
return {
|
||||||
|
calendarId: form.calendarId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
startDateTime: start.toISOString(),
|
||||||
|
endDateTime: end.toISOString(),
|
||||||
|
location: form.location.trim(),
|
||||||
|
url: url.ok ? url.value : "",
|
||||||
|
wholeDay: form.wholeDay,
|
||||||
|
repeatFrequency: form.repeatFrequency,
|
||||||
|
status: form.status,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The same payload, with the status changed - for the list's quick "Veröffentlichen". */
|
||||||
|
export const payloadFromEvent = (event: CalendarEvent, status: EventStatus = event.status): EventPayload => ({
|
||||||
|
calendarId: event.calendarId,
|
||||||
|
name: event.name,
|
||||||
|
description: event.description,
|
||||||
|
startDateTime: event.start.toISOString(),
|
||||||
|
endDateTime: event.end.toISOString(),
|
||||||
|
location: event.location,
|
||||||
|
url: event.url,
|
||||||
|
wholeDay: event.wholeDay,
|
||||||
|
repeatFrequency: event.repeatFrequency,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isFormDirty = (initial: EventForm, current: EventForm): boolean =>
|
||||||
|
(Object.keys(initial) as EventFormField[]).some((key) => initial[key] !== current[key]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether anything besides the calendar changed. The move endpoint only
|
||||||
|
* rewrites `calendar_id`, so a pure move needs no update call - and making
|
||||||
|
* one anyway would add an event version that changes nothing.
|
||||||
|
*/
|
||||||
|
export const contentChanged = (initial: EventForm, current: EventForm): boolean =>
|
||||||
|
isFormDirty({ ...initial, calendarId: 0 }, { ...current, calendarId: 0 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event as the list should show it right after a save, before the
|
||||||
|
* re-fetch brings the server's own fields (uuid, exact timestamps). `previous`
|
||||||
|
* is the event before the edit; absent for a new one.
|
||||||
|
*/
|
||||||
|
export const eventFromPayload = (
|
||||||
|
eventId: number,
|
||||||
|
payload: EventPayload,
|
||||||
|
author: string,
|
||||||
|
now: Date,
|
||||||
|
previous?: CalendarEvent,
|
||||||
|
): CalendarEvent => ({
|
||||||
|
eventId,
|
||||||
|
calendarId: payload.calendarId,
|
||||||
|
uuid: previous?.uuid ?? "",
|
||||||
|
name: payload.name,
|
||||||
|
description: payload.description,
|
||||||
|
start: new Date(payload.startDateTime),
|
||||||
|
end: new Date(payload.endDateTime),
|
||||||
|
created: previous?.created ?? now,
|
||||||
|
lastModified: now,
|
||||||
|
location: payload.location,
|
||||||
|
createdBy: previous?.createdBy ?? author,
|
||||||
|
lastModifiedBy: author,
|
||||||
|
url: payload.url,
|
||||||
|
wholeDay: payload.wholeDay,
|
||||||
|
repeatFrequency: payload.repeatFrequency,
|
||||||
|
status: payload.status,
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseApiEvent, parseApiEvents } from "@/lib/events";
|
||||||
|
import type { ApiEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
const row = (overrides: Partial<ApiEvent> = {}): ApiEvent => ({
|
||||||
|
eventId: 7,
|
||||||
|
calendarId: 5,
|
||||||
|
uuid: "u-7",
|
||||||
|
name: "Anna",
|
||||||
|
description: null,
|
||||||
|
startDateTime: "1990-10-02T22:00:00.000Z",
|
||||||
|
endDateTime: "1990-10-03T21:59:00.000Z",
|
||||||
|
createdDate: "2024-01-01T10:00:00.000Z",
|
||||||
|
lastModifiedDate: "2024-02-01T10:00:00.000Z",
|
||||||
|
location: null,
|
||||||
|
createdBy: null,
|
||||||
|
lastModifiedBy: "Paula",
|
||||||
|
url: null,
|
||||||
|
wholeDay: 1,
|
||||||
|
repeatFrequency: "YEARLY",
|
||||||
|
status: "DRAFT",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseApiEvent", () => {
|
||||||
|
it("coerces the raw row: 1 → true, nulls → empty strings, ISO → Date", () => {
|
||||||
|
const event = parseApiEvent(row());
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
eventId: 7,
|
||||||
|
wholeDay: true,
|
||||||
|
description: "",
|
||||||
|
location: "",
|
||||||
|
url: "",
|
||||||
|
createdBy: "",
|
||||||
|
lastModifiedBy: "Paula",
|
||||||
|
repeatFrequency: "YEARLY",
|
||||||
|
status: "DRAFT",
|
||||||
|
});
|
||||||
|
expect(event?.start.toISOString()).toBe("1990-10-02T22:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops deleted events", () => {
|
||||||
|
expect(parseApiEvent(row({ status: "DELETED" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a row without a usable start or end - what a deleted version looks like", () => {
|
||||||
|
expect(parseApiEvent(row({ status: null, startDateTime: null }))).toBeNull();
|
||||||
|
expect(parseApiEvent(row({ endDateTime: "not a date" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a missing status as PUBLIC (the public listing omits it)", () => {
|
||||||
|
const withoutStatus = row();
|
||||||
|
delete withoutStatus.status;
|
||||||
|
expect(parseApiEvent(withoutStatus)?.status).toBe("PUBLIC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never promotes an unknown status to PUBLIC", () => {
|
||||||
|
expect(parseApiEvent(row({ status: "ARCHIVED" }))?.status).toBe("PRIVATE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads 0 and null as not whole-day", () => {
|
||||||
|
expect(parseApiEvent(row({ wholeDay: 0 }))?.wholeDay).toBe(false);
|
||||||
|
expect(parseApiEvent(row({ wholeDay: null }))?.wholeDay).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseApiEvents", () => {
|
||||||
|
it("keeps only the rows worth showing", () => {
|
||||||
|
const events = parseApiEvents([row({ eventId: 1 }), row({ eventId: 2, status: "DELETED" }), row({ eventId: 3 })]);
|
||||||
|
expect(events.map((e) => e.eventId)).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ApiEvent, CalendarEvent, EventStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const STATUSES: readonly EventStatus[] = ["PUBLIC", "PRIVATE", "DRAFT", "DELETED"];
|
||||||
|
|
||||||
|
const parseDate = (value: string | null | undefined): Date | null => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns one row of the API's JSON into a `CalendarEvent`, or null for a row
|
||||||
|
* the list should not show.
|
||||||
|
*
|
||||||
|
* Null covers deleted events - the admin feed returns them, because deleting
|
||||||
|
* only appends a version with `status = 'DELETED'` - and anything without a
|
||||||
|
* usable start and end, which a deleted row also is (all its other columns are
|
||||||
|
* null). The Angular app filtered on status alone and would have rendered such
|
||||||
|
* a row as "Invalid Date".
|
||||||
|
*
|
||||||
|
* A missing status (the anonymous public listing omits it) means PUBLIC, since
|
||||||
|
* that listing only returns published events. An unknown one is treated as
|
||||||
|
* PRIVATE (and saved back as such) - "not public" is the safe reading; it is
|
||||||
|
* never silently promoted to PUBLIC.
|
||||||
|
*/
|
||||||
|
export const parseApiEvent = (row: ApiEvent): CalendarEvent | null => {
|
||||||
|
const status: EventStatus =
|
||||||
|
row.status == null ? "PUBLIC" : STATUSES.includes(row.status as EventStatus) ? (row.status as EventStatus) : "PRIVATE";
|
||||||
|
if (status === "DELETED") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = parseDate(row.startDateTime);
|
||||||
|
const end = parseDate(row.endDateTime);
|
||||||
|
if (!start || !end) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId: row.eventId,
|
||||||
|
calendarId: row.calendarId,
|
||||||
|
uuid: row.uuid ?? "",
|
||||||
|
name: row.name ?? "",
|
||||||
|
description: row.description ?? "",
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
created: parseDate(row.createdDate),
|
||||||
|
lastModified: parseDate(row.lastModifiedDate),
|
||||||
|
location: row.location ?? "",
|
||||||
|
createdBy: row.createdBy ?? "",
|
||||||
|
lastModifiedBy: row.lastModifiedBy ?? "",
|
||||||
|
url: row.url ?? "",
|
||||||
|
wholeDay: Boolean(row.wholeDay),
|
||||||
|
repeatFrequency: row.repeatFrequency ?? "",
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseApiEvents = (rows: ApiEvent[]): CalendarEvent[] =>
|
||||||
|
rows.map(parseApiEvent).filter((e): e is CalendarEvent => e !== null);
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fromWallTime } from "@/lib/dates";
|
||||||
|
import { filterEvents, groupByMonth, matchesQuery, matchesRange, sortEvents } from "@/lib/filter";
|
||||||
|
import { makeEvent } from "@/lib/test-fixtures";
|
||||||
|
|
||||||
|
const now = new Date("2026-09-27T10:00:00Z"); // 12:00 Berlin
|
||||||
|
|
||||||
|
describe("matchesRange", () => {
|
||||||
|
it("keeps an event that ended earlier today in 'future' until midnight", () => {
|
||||||
|
const earlierToday = makeEvent({
|
||||||
|
start: fromWallTime("2026-09-27", "08:00"),
|
||||||
|
end: fromWallTime("2026-09-27", "09:00"),
|
||||||
|
});
|
||||||
|
expect(matchesRange(earlierToday, "future", now)).toBe(true);
|
||||||
|
expect(matchesRange(earlierToday, "past", now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts yesterday's event in 'past'", () => {
|
||||||
|
const yesterday = makeEvent({ start: fromWallTime("2026-09-26", "19:00"), end: fromWallTime("2026-09-26", "21:00") });
|
||||||
|
expect(matchesRange(yesterday, "past", now)).toBe(true);
|
||||||
|
expect(matchesRange(yesterday, "future", now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats any repeating event as current, whatever its calendar", () => {
|
||||||
|
const old = { start: fromWallTime("1990-01-01", "00:00"), end: fromWallTime("1990-01-01", "23:59") };
|
||||||
|
const birthday = makeEvent({ ...old, calendarId: 5, repeatFrequency: "YEARLY" });
|
||||||
|
const weeklyRehearsal = makeEvent({ ...old, calendarId: 4, repeatFrequency: "WEEKLY" });
|
||||||
|
for (const e of [birthday, weeklyRehearsal]) {
|
||||||
|
expect(matchesRange(e, "future", now)).toBe(true);
|
||||||
|
expect(matchesRange(e, "past", now)).toBe(false);
|
||||||
|
expect(matchesRange(e, "all", now)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no longer exempts a non-repeating event just for being in Birthdays", () => {
|
||||||
|
const oneOff = makeEvent({ calendarId: 5, start: fromWallTime("2020-01-01", "00:00"), end: fromWallTime("2020-01-01", "23:59") });
|
||||||
|
expect(matchesRange(oneOff, "future", now)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matchesQuery", () => {
|
||||||
|
const event = makeEvent({ name: "Probe Weihnachtskonzert", location: "Gemeindehaus Süd" });
|
||||||
|
|
||||||
|
it("matches title and location, case-insensitively, all words", () => {
|
||||||
|
expect(matchesQuery(event, "")).toBe(true);
|
||||||
|
expect(matchesQuery(event, "weihnacht")).toBe(true);
|
||||||
|
expect(matchesQuery(event, "PROBE süd")).toBe(true);
|
||||||
|
expect(matchesQuery(event, "probe nord")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterEvents", () => {
|
||||||
|
it("combines calendar, range and search", () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ eventId: 1, calendarId: 1, name: "Konzert" }),
|
||||||
|
makeEvent({ eventId: 2, calendarId: 2, name: "Konzert" }),
|
||||||
|
makeEvent({ eventId: 3, calendarId: 1, name: "Probe" }),
|
||||||
|
];
|
||||||
|
const result = filterEvents(events, { calendarIds: [1], range: "future", query: "konz" }, now);
|
||||||
|
expect(result.map((e) => e.eventId)).toEqual([1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortEvents", () => {
|
||||||
|
const a = makeEvent({ eventId: 1, name: "B", start: fromWallTime("2026-10-02", "19:00") });
|
||||||
|
const b = makeEvent({ eventId: 2, name: "A", start: fromWallTime("2026-10-02", "19:00") });
|
||||||
|
const c = makeEvent({ eventId: 3, name: "C", start: fromWallTime("2026-10-01", "19:00") });
|
||||||
|
|
||||||
|
it("orders by start and breaks ties by title", () => {
|
||||||
|
expect(sortEvents([a, b, c], "start_asc", now).map((e) => e.eventId)).toEqual([3, 2, 1]);
|
||||||
|
// Descending flips the dates only; equal starts still read A before B.
|
||||||
|
expect(sortEvents([a, b, c], "start_desc", now).map((e) => e.eventId)).toEqual([2, 1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate its input", () => {
|
||||||
|
const input = [a, b, c];
|
||||||
|
sortEvents(input, "start_asc", now);
|
||||||
|
expect(input.map((e) => e.eventId)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts a birthday by its next occurrence, not its year", () => {
|
||||||
|
const birthday = makeEvent({ eventId: 9, repeatFrequency: "YEARLY", start: fromWallTime("1990-10-01", "12:00"), end: fromWallTime("1990-10-01", "23:59") });
|
||||||
|
const concert = makeEvent({ eventId: 8, start: fromWallTime("2026-10-02", "19:00") });
|
||||||
|
expect(sortEvents([concert, birthday], "start_asc", now).map((e) => e.eventId)).toEqual([9, 8]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts by created date, missing ones first", () => {
|
||||||
|
const x = makeEvent({ eventId: 1, created: new Date("2026-05-01T00:00:00Z") });
|
||||||
|
const y = makeEvent({ eventId: 2, created: null });
|
||||||
|
expect(sortEvents([x, y], "created_asc", now).map((e) => e.eventId)).toEqual([2, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupByMonth", () => {
|
||||||
|
it("groups consecutive events by Berlin month, labelled in German", () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ eventId: 1, start: fromWallTime("2026-10-01", "00:30") }), // still 30 Sep in UTC
|
||||||
|
makeEvent({ eventId: 2, start: fromWallTime("2026-10-20", "19:00") }),
|
||||||
|
makeEvent({ eventId: 3, start: fromWallTime("2026-11-02", "19:00") }),
|
||||||
|
];
|
||||||
|
const groups = groupByMonth(events, now);
|
||||||
|
expect(groups.map((g) => [g.key, g.label, g.events.map((e) => e.eventId)])).toEqual([
|
||||||
|
["2026-10", "Oktober 2026", [1, 2]],
|
||||||
|
["2026-11", "November 2026", [3]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
import { berlinDay, formatMonth, monthKey, nextOccurrence } from "@/lib/dates";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtering, sorting and grouping for the admin list. All client-side: the
|
||||||
|
* Angular app re-fetched from the server every time the filter changed, even
|
||||||
|
* though the data did not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Range = "future" | "past" | "all";
|
||||||
|
export type Sort = "start_asc" | "start_desc" | "created_asc" | "created_desc";
|
||||||
|
|
||||||
|
export const RANGES: readonly Range[] = ["future", "past", "all"];
|
||||||
|
export const SORTS: readonly Sort[] = ["start_asc", "start_desc", "created_asc", "created_desc"];
|
||||||
|
|
||||||
|
export interface ListFilter {
|
||||||
|
calendarIds: readonly number[];
|
||||||
|
range: Range;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isRecurring = (event: CalendarEvent): boolean => event.repeatFrequency !== "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the event next happens, for sorting and grouping. A repeating event's
|
||||||
|
* original start can be decades back (a birthday is stored with the year of
|
||||||
|
* birth); what matters in the list is the next time it comes round.
|
||||||
|
*/
|
||||||
|
export const effectiveStart = (event: CalendarEvent, now: Date): Date =>
|
||||||
|
isRecurring(event) ? nextOccurrence(event.start, event.repeatFrequency, now) : event.start;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repeating events always count as current - under "future" and "all", never
|
||||||
|
* under "past". This replaces the Angular rule "calendar 5 ignores the
|
||||||
|
* filter", which keyed on the calendar rather than on the thing that actually
|
||||||
|
* makes an event timeless.
|
||||||
|
*
|
||||||
|
* "future" compares Berlin days, not instants, so an event that ended earlier
|
||||||
|
* today is still listed until midnight.
|
||||||
|
*/
|
||||||
|
export const matchesRange = (event: CalendarEvent, range: Range, now: Date): boolean => {
|
||||||
|
if (range === "all") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isRecurring(event)) {
|
||||||
|
return range === "future";
|
||||||
|
}
|
||||||
|
const upcoming = berlinDay(event.end) >= berlinDay(now);
|
||||||
|
return range === "future" ? upcoming : !upcoming;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalise = (s: string): string => s.toLocaleLowerCase("de-DE").normalize("NFC");
|
||||||
|
|
||||||
|
/** Case-insensitive substring match on title and location; every word must match. */
|
||||||
|
export const matchesQuery = (event: CalendarEvent, query: string): boolean => {
|
||||||
|
const words = normalise(query).split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const haystack = normalise(`${event.name} ${event.location}`);
|
||||||
|
return words.every((w) => haystack.includes(w));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterEvents = (events: readonly CalendarEvent[], filter: ListFilter, now: Date): CalendarEvent[] =>
|
||||||
|
events.filter(
|
||||||
|
(e) => filter.calendarIds.includes(e.calendarId) && matchesRange(e, filter.range, now) && matchesQuery(e, filter.query),
|
||||||
|
);
|
||||||
|
|
||||||
|
const byName = (a: CalendarEvent, b: CalendarEvent): number => a.name.localeCompare(b.name, "de-DE");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable, total order - ties fall back to the title and then the id. The
|
||||||
|
* Angular comparator returned only 1 or -1, never 0, which is not a valid
|
||||||
|
* comparator and let equal events swap places between renders.
|
||||||
|
*/
|
||||||
|
export const sortEvents = (events: readonly CalendarEvent[], sort: Sort, now: Date): CalendarEvent[] => {
|
||||||
|
const key = (e: CalendarEvent): number =>
|
||||||
|
sort.startsWith("created") ? (e.created?.getTime() ?? 0) : effectiveStart(e, now).getTime();
|
||||||
|
const direction = sort.endsWith("desc") ? -1 : 1;
|
||||||
|
return [...events].sort((a, b) => (key(a) - key(b)) * direction || byName(a, b) || a.eventId - b.eventId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MonthGroup {
|
||||||
|
/** "2026-10" */
|
||||||
|
key: string;
|
||||||
|
/** "Oktober 2026" */
|
||||||
|
label: string;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consecutive runs of events in the same month (by effective start), in the
|
||||||
|
* order given. Meant for a list sorted by start; with another order, a month
|
||||||
|
* can legitimately appear more than once.
|
||||||
|
*/
|
||||||
|
export const groupByMonth = (events: readonly CalendarEvent[], now: Date): MonthGroup[] => {
|
||||||
|
const groups: MonthGroup[] = [];
|
||||||
|
for (const event of events) {
|
||||||
|
const start = effectiveStart(event, now);
|
||||||
|
const key = monthKey(start);
|
||||||
|
const last = groups.at(-1);
|
||||||
|
if (last && last.key === key) {
|
||||||
|
last.events.push(event);
|
||||||
|
} else {
|
||||||
|
groups.push({ key, label: formatMonth(start), events: [event] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
fetchUpcomingPublicEvents,
|
||||||
|
googleCalendarUrl,
|
||||||
|
icalUrl,
|
||||||
|
selectUpcoming,
|
||||||
|
toGoogleCalendar,
|
||||||
|
toWebcal,
|
||||||
|
webcalUrl,
|
||||||
|
} from "@/lib/public-calendar";
|
||||||
|
import type { ApiEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
const now = new Date("2026-09-28T10:00:00Z");
|
||||||
|
|
||||||
|
const row = (eventId: number, start: string, end: string, name = `E${eventId}`): ApiEvent => ({
|
||||||
|
eventId,
|
||||||
|
calendarId: 1,
|
||||||
|
uuid: `u${eventId}`,
|
||||||
|
name,
|
||||||
|
description: "",
|
||||||
|
startDateTime: start,
|
||||||
|
endDateTime: end,
|
||||||
|
createdDate: null,
|
||||||
|
location: "",
|
||||||
|
url: "",
|
||||||
|
wholeDay: 0,
|
||||||
|
repeatFrequency: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscription links", () => {
|
||||||
|
it("builds the iCal, webcal and Google links from the API URL", () => {
|
||||||
|
expect(icalUrl("https://api.nachklang.art")).toBe("https://api.nachklang.art/calendar/events/public/ical");
|
||||||
|
expect(webcalUrl("https://api.nachklang.art")).toBe("webcal://api.nachklang.art/calendar/events/public/ical");
|
||||||
|
expect(webcalUrl("http://localhost:3000")).toBe("webcal://localhost:3000/calendar/events/public/ical");
|
||||||
|
// The same link the Angular landing page hardcoded.
|
||||||
|
expect(googleCalendarUrl("https://api.nachklang.art")).toBe(
|
||||||
|
"https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2Fapi.nachklang.art%2Fcalendar%2Fevents%2Fpublic%2Fical",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("links for a password-protected calendar", () => {
|
||||||
|
it("keeps the password through the webcal and Google conversions", () => {
|
||||||
|
const ical = "https://api.nachklang.art/calendar/events/choir/ical?password=a%26b";
|
||||||
|
expect(toWebcal(ical)).toBe("webcal://api.nachklang.art/calendar/events/choir/ical?password=a%26b");
|
||||||
|
// Encoded once more as a whole, so Google hands back exactly the URL above.
|
||||||
|
const cid = new URL(toGoogleCalendar(ical)).searchParams.get("cid");
|
||||||
|
expect(cid).toBe("webcal://api.nachklang.art/calendar/events/choir/ical?password=a%26b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectUpcoming", () => {
|
||||||
|
it("keeps events not yet over, earliest first, up to the limit", () => {
|
||||||
|
const rows = [
|
||||||
|
row(1, "2026-11-01T18:00:00Z", "2026-11-01T20:00:00Z"),
|
||||||
|
row(2, "2026-07-01T18:00:00Z", "2026-07-01T20:00:00Z"), // over
|
||||||
|
row(3, "2026-09-28T06:00:00Z", "2026-09-28T07:00:00Z"), // earlier today: still listed
|
||||||
|
row(4, "2026-10-01T18:00:00Z", "2026-10-01T20:00:00Z"),
|
||||||
|
];
|
||||||
|
expect(selectUpcoming(rows, now, 2).map((e) => e.eventId)).toEqual([3, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchUpcomingPublicEvents", () => {
|
||||||
|
it("returns null instead of throwing when the API is unreachable or unhappy", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("fetch failed")));
|
||||||
|
await expect(fetchUpcomingPublicEvents("https://api.test", now, 3)).resolves.toBeNull();
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 500 })));
|
||||||
|
await expect(fetchUpcomingPublicEvents("https://api.test", now, 3)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses what the public feed returns", async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify([row(1, "2026-11-01T18:00:00Z", "2026-11-01T20:00:00Z")]), { status: 200 }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetch);
|
||||||
|
const events = await fetchUpcomingPublicEvents("https://api.test", now, 3);
|
||||||
|
expect(events?.map((e) => e.eventId)).toEqual([1]);
|
||||||
|
expect(fetch).toHaveBeenCalledWith("https://api.test/calendar/events/public/json", { next: { revalidate: 300 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { parseApiEvents } from "@/lib/events";
|
||||||
|
import { matchesRange, sortEvents } from "@/lib/filter";
|
||||||
|
import type { ApiEvent, CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public calendar as the landing page shows it: subscription links and
|
||||||
|
* the next few events. Pure, apart from `fetchUpcomingPublicEvents`.
|
||||||
|
*
|
||||||
|
* The public feed needs no credential at all - nachklang.art reads the same
|
||||||
|
* endpoint anonymously - so this runs on the server without a session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PUBLIC_PATH = "/calendar/events/public";
|
||||||
|
|
||||||
|
/** https://api.nachklang.art/calendar/events/public/ical */
|
||||||
|
export const icalUrl = (apiBaseUrl: string): string => `${apiBaseUrl}${PUBLIC_PATH}/ical`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any iCal URL as a `webcal://` link, which Apple Calendar (and most desktop
|
||||||
|
* clients) open as "subscribe" rather than "download". The scheme replaces
|
||||||
|
* http(s) whichever the API uses - calendar apps fetch it over https for an
|
||||||
|
* https host.
|
||||||
|
*/
|
||||||
|
export const toWebcal = (ical: string): string => ical.replace(/^https?:\/\//, "webcal://");
|
||||||
|
|
||||||
|
/** Google Calendar's "add by URL" deep link for any iCal URL. It wants the webcal form, URL-encoded. */
|
||||||
|
export const toGoogleCalendar = (ical: string): string =>
|
||||||
|
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(toWebcal(ical))}`;
|
||||||
|
|
||||||
|
export const webcalUrl = (apiBaseUrl: string): string => toWebcal(icalUrl(apiBaseUrl));
|
||||||
|
|
||||||
|
export const googleCalendarUrl = (apiBaseUrl: string): string => toGoogleCalendar(icalUrl(apiBaseUrl));
|
||||||
|
|
||||||
|
/** Upcoming (not yet over, Berlin days) public events, earliest first. */
|
||||||
|
export const selectUpcoming = (rows: ApiEvent[], now: Date, limit: number): CalendarEvent[] =>
|
||||||
|
sortEvents(
|
||||||
|
parseApiEvents(rows).filter((e) => matchesRange(e, "future", now)),
|
||||||
|
"start_asc",
|
||||||
|
now,
|
||||||
|
).slice(0, limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side fetch, cached for five minutes (Next's `revalidate`), so the
|
||||||
|
* landing page is static HTML that refreshes itself rather than a request to
|
||||||
|
* the API per visitor.
|
||||||
|
*
|
||||||
|
* Null on any failure. The subscription links are the page's real job and
|
||||||
|
* must not depend on the API answering - that includes `next build` on a
|
||||||
|
* machine that cannot reach it.
|
||||||
|
*/
|
||||||
|
export const fetchUpcomingPublicEvents = async (
|
||||||
|
apiBaseUrl: string,
|
||||||
|
now: Date,
|
||||||
|
limit: number,
|
||||||
|
): Promise<CalendarEvent[] | null> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBaseUrl}${PUBLIC_PATH}/json`, { next: { revalidate: 300 } });
|
||||||
|
if (!res.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return selectUpcoming((await res.json()) as ApiEvent[], now, limit);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { fromWallTime } from "@/lib/dates";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
/** A plain, published, timed event; override what the test is about. Test-only. */
|
||||||
|
export const makeEvent = (overrides: Partial<CalendarEvent> = {}): CalendarEvent => ({
|
||||||
|
eventId: 1,
|
||||||
|
calendarId: 1,
|
||||||
|
uuid: "uuid-1",
|
||||||
|
name: "Konzert",
|
||||||
|
description: "",
|
||||||
|
start: fromWallTime("2026-10-03", "19:00"),
|
||||||
|
end: fromWallTime("2026-10-03", "21:00"),
|
||||||
|
created: new Date("2026-01-01T12:00:00Z"),
|
||||||
|
lastModified: null,
|
||||||
|
location: "Karlsruhe",
|
||||||
|
createdBy: "",
|
||||||
|
lastModifiedBy: "",
|
||||||
|
url: "",
|
||||||
|
wholeDay: false,
|
||||||
|
repeatFrequency: "",
|
||||||
|
status: "PUBLIC",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DEFAULT_LIST_STATE, parseListState, serializeListState, toQueryString, withRange } from "@/lib/url-state";
|
||||||
|
|
||||||
|
const parse = (qs: string) => parseListState(new URLSearchParams(qs));
|
||||||
|
|
||||||
|
describe("parseListState", () => {
|
||||||
|
it("defaults to every calendar except Birthdays, upcoming, by start", () => {
|
||||||
|
expect(parse("")).toEqual({ calendarIds: [1, 2, 4, 3], range: "future", sort: "start_asc", query: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads calendars by slug, dropping unknown ones and duplicates", () => {
|
||||||
|
expect(parse("cal=choir,nope,birthdays,choir").calendarIds).toEqual([4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicitly empty selection", () => {
|
||||||
|
expect(parse("cal=").calendarIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts the past newest first by default", () => {
|
||||||
|
expect(parse("range=past").sort).toBe("start_desc");
|
||||||
|
expect(parse("range=past&sort=start_asc").sort).toBe("start_asc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaults for unknown range and sort", () => {
|
||||||
|
expect(parse("range=soon&sort=random")).toMatchObject({ range: "future", sort: "start_asc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads range, sort and search", () => {
|
||||||
|
expect(parse("range=past&sort=created_desc&q=probe")).toMatchObject({
|
||||||
|
range: "past",
|
||||||
|
sort: "created_desc",
|
||||||
|
query: "probe",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serializeListState", () => {
|
||||||
|
it("leaves the default view out of the URL", () => {
|
||||||
|
expect(serializeListState(DEFAULT_LIST_STATE).toString()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats the default calendars in another order as the default", () => {
|
||||||
|
expect(serializeListState({ ...DEFAULT_LIST_STATE, calendarIds: [3, 4, 2, 1] }).toString()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a non-default view", () => {
|
||||||
|
const state = { calendarIds: [5], range: "all" as const, sort: "start_desc" as const, query: "geburtstag" };
|
||||||
|
expect(parseListState(serializeListState(state))).toEqual(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes an empty selection as cal=", () => {
|
||||||
|
expect(serializeListState({ ...DEFAULT_LIST_STATE, calendarIds: [] }).toString()).toBe("cal=");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withRange", () => {
|
||||||
|
it("swaps a default sort for the new range's default, keeping the URL clean", () => {
|
||||||
|
const past = withRange(DEFAULT_LIST_STATE, "past");
|
||||||
|
expect(past.sort).toBe("start_desc");
|
||||||
|
expect(serializeListState(past).toString()).toBe("range=past");
|
||||||
|
expect(withRange(past, "all").sort).toBe("start_asc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a sort someone picked", () => {
|
||||||
|
const picked = { ...DEFAULT_LIST_STATE, sort: "created_desc" as const };
|
||||||
|
expect(withRange(picked, "past").sort).toBe("created_desc");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toQueryString", () => {
|
||||||
|
it("keeps the calendar list readable but escapes commas in the search", () => {
|
||||||
|
const qs = toQueryString({ ...DEFAULT_LIST_STATE, calendarIds: [1, 4], query: "a,b" });
|
||||||
|
expect(qs).toBe("cal=public,choir&q=a%2Cb");
|
||||||
|
expect(parseListState(new URLSearchParams(qs))).toMatchObject({ calendarIds: [1, 4], query: "a,b" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { calendarById, calendarBySlug, DEFAULT_VISIBLE_CALENDAR_IDS } from "@/lib/calendars";
|
||||||
|
import { RANGES, SORTS, type Range, type Sort } from "@/lib/filter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin list's view state, kept in the query string so a link reproduces
|
||||||
|
* a view: `?cal=public,choir&range=past&sort=start_desc&q=probe`.
|
||||||
|
*
|
||||||
|
* Defaults are left out of the URL, so the plain `/admin` link means "the
|
||||||
|
* default view". Anything unrecognised falls back to its default instead of
|
||||||
|
* failing - these URLs get pasted into chats and bookmarked.
|
||||||
|
*
|
||||||
|
* `cal=` with an empty value is kept as "no calendars selected"; only a
|
||||||
|
* missing `cal` means the default selection.
|
||||||
|
*
|
||||||
|
* The default sort depends on the range: looking back ("Vergangen") starts
|
||||||
|
* with the most recent event, everything else with the earliest. A sort that
|
||||||
|
* matches its range's default stays out of the URL, so switching ranges
|
||||||
|
* carries the right default along; a sort someone picked is kept.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ListState {
|
||||||
|
calendarIds: number[];
|
||||||
|
range: Range;
|
||||||
|
sort: Sort;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LIST_STATE: ListState = {
|
||||||
|
calendarIds: [...DEFAULT_VISIBLE_CALENDAR_IDS],
|
||||||
|
range: "future",
|
||||||
|
sort: "start_asc",
|
||||||
|
query: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultSortFor = (range: Range): Sort => (range === "past" ? "start_desc" : "start_asc");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state after switching to `range`: a sort that was only the old range's
|
||||||
|
* default becomes the new range's default; one that someone picked stays.
|
||||||
|
*/
|
||||||
|
export const withRange = (state: ListState, range: Range): ListState => ({
|
||||||
|
...state,
|
||||||
|
range,
|
||||||
|
sort: state.sort === defaultSortFor(state.range) ? defaultSortFor(range) : state.sort,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sameIds = (a: readonly number[], b: readonly number[]): boolean => {
|
||||||
|
const sortedB = [...b].sort();
|
||||||
|
return a.length === b.length && [...a].sort().every((id, i) => id === sortedB[i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseListState = (params: URLSearchParams): ListState => {
|
||||||
|
const cal = params.get("cal");
|
||||||
|
const calendarIds =
|
||||||
|
cal === null
|
||||||
|
? [...DEFAULT_LIST_STATE.calendarIds]
|
||||||
|
: [
|
||||||
|
...new Set(
|
||||||
|
cal
|
||||||
|
.split(",")
|
||||||
|
.map((slug) => calendarBySlug(slug.trim())?.id)
|
||||||
|
.filter((id): id is number => id !== undefined),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rawRange = params.get("range");
|
||||||
|
const rawSort = params.get("sort");
|
||||||
|
const range = RANGES.includes(rawRange as Range) ? (rawRange as Range) : DEFAULT_LIST_STATE.range;
|
||||||
|
|
||||||
|
return {
|
||||||
|
calendarIds,
|
||||||
|
range,
|
||||||
|
sort: SORTS.includes(rawSort as Sort) ? (rawSort as Sort) : defaultSortFor(range),
|
||||||
|
query: params.get("q") ?? "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeListState = (state: ListState): URLSearchParams => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (!sameIds(state.calendarIds, DEFAULT_LIST_STATE.calendarIds)) {
|
||||||
|
params.set(
|
||||||
|
"cal",
|
||||||
|
state.calendarIds
|
||||||
|
.map((id) => calendarById(id)?.slug)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(","),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.range !== DEFAULT_LIST_STATE.range) {
|
||||||
|
params.set("range", state.range);
|
||||||
|
}
|
||||||
|
if (state.sort !== defaultSortFor(state.range)) {
|
||||||
|
params.set("sort", state.sort);
|
||||||
|
}
|
||||||
|
if (state.query.trim() !== "") {
|
||||||
|
params.set("q", state.query);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query string for the address bar, without the leading "?". Commas stay
|
||||||
|
* literal (`cal=public,choir`) because these URLs are meant to be read and
|
||||||
|
* shared; URLSearchParams would write `%2C`. A comma inside the search text
|
||||||
|
* is escaped by hand first, so it cannot be mistaken for a separator - and
|
||||||
|
* the parser only splits `cal` anyway.
|
||||||
|
*/
|
||||||
|
export const toQueryString = (state: ListState): string =>
|
||||||
|
serializeListState(state)
|
||||||
|
.toString()
|
||||||
|
.replace(/(^|&)cal=([^&]*)/, (_, lead: string, value: string) => `${lead}cal=${value.replace(/%2C/gi, ",")}`);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import { networkInterfaces } from "os";
|
||||||
|
|
||||||
|
// Next.js 16's dev server blocks cross-origin access to dev-only resources
|
||||||
|
// (the HMR websocket, etc.) unless the origin is allowlisted - which silently
|
||||||
|
// breaks hydration when testing from a real phone over WiFi, since the phone's
|
||||||
|
// origin is the dev machine's LAN IP, never "localhost". Derived dynamically
|
||||||
|
// because DHCP can hand out a different IP on a later session. Only affects
|
||||||
|
// `next dev`. Copied from nachklang-feedback.
|
||||||
|
const lanIps = Object.values(networkInterfaces())
|
||||||
|
.flat()
|
||||||
|
.filter((iface): iface is NonNullable<typeof iface> => !!iface && iface.family === "IPv4" && !iface.internal)
|
||||||
|
.map((iface) => iface.address);
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Self-contained server.js for Plesk's Passenger Node.js toolkit, the same
|
||||||
|
// way nachklang-feedback is deployed.
|
||||||
|
output: "standalone",
|
||||||
|
allowedDevOrigins: lanIps,
|
||||||
|
turbopack: {
|
||||||
|
root: __dirname,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+8694
-21591
File diff suppressed because it is too large.
Load diff
+31
-32
@@ -1,40 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "nachklang-calendar",
|
"name": "nachklang-calendar",
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"scripts": {
|
|
||||||
"ng": "ng",
|
|
||||||
"start": "ng serve",
|
|
||||||
"build": "ng build",
|
|
||||||
"watch": "ng build --watch --configuration development",
|
|
||||||
"test": "ng test"
|
|
||||||
},
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 4200",
|
||||||
|
"build": "next build",
|
||||||
|
"postbuild": "node -e \"require('fs').cpSync('.next/static', '.next/standalone/.next/static', {recursive: true})\"",
|
||||||
|
"start": "next start -p 4200",
|
||||||
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^18.0.1",
|
"@base-ui/react": "^1.7.0",
|
||||||
"@angular/common": "^18.0.1",
|
"@date-fns/tz": "^1.5.0",
|
||||||
"@angular/compiler": "^18.0.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"@angular/core": "^18.0.1",
|
"clsx": "^2.1.1",
|
||||||
"@angular/forms": "^18.0.1",
|
"lucide-react": "^1.33.0",
|
||||||
"@angular/material": "^18.0.1",
|
"next": "16.2.12",
|
||||||
"@angular/platform-browser": "^18.0.1",
|
"react": "19.2.4",
|
||||||
"@angular/platform-browser-dynamic": "^18.0.1",
|
"react-dom": "19.2.4",
|
||||||
"@angular/router": "^18.0.1",
|
"shadcn": "^4.19.0",
|
||||||
"@material/dialog": "^15.0.0-canary.7f224ddd4.0",
|
"sonner": "^2.0.8",
|
||||||
"rxjs": "~7.8.1",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tslib": "^2.6.2",
|
"tw-animate-css": "^1.4.0"
|
||||||
"zone.js": "~0.14.6"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^18.0.2",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@angular/cli": "~18.0.2",
|
"@types/node": "^26",
|
||||||
"@angular/compiler-cli": "^18.0.1",
|
"@types/react": "^19",
|
||||||
"@types/jasmine": "~5.1.4",
|
"@types/react-dom": "^19",
|
||||||
"jasmine-core": "~5.1.2",
|
"eslint": "^9",
|
||||||
"karma": "~6.4.3",
|
"eslint-config-next": "16.2.12",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"tailwindcss": "^4",
|
||||||
"karma-coverage": "~2.2.1",
|
"typescript": "^5",
|
||||||
"karma-jasmine": "~5.1.0",
|
"vitest": "^5.0.2"
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
|
||||||
"typescript": "~5.4.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors proxy.ts in nachklang-feedback (and the gate in nachklang-admin and
|
||||||
|
* nachklang-tickets) - keep them in step.
|
||||||
|
*
|
||||||
|
* A *convenience* gate, not a security boundary: it only checks that a session
|
||||||
|
* cookie exists, so a forged value gets past it. That is fine - the API checks
|
||||||
|
* every request, and the calendar's write routes sit behind
|
||||||
|
* requireAppAccess('calendar'). What this buys is that a signed-out visitor
|
||||||
|
* lands on the sign-in page straight away instead of on an admin shell that
|
||||||
|
* flashes and then redirects.
|
||||||
|
*
|
||||||
|
* Only the cookie's *presence* is readable: it is httpOnly and set by the API
|
||||||
|
* on another host. Cookies are scoped by host, not port, so in dev the cookie
|
||||||
|
* the API sets for localhost:3000 is visible here on localhost:4200 too. This
|
||||||
|
* gate cannot see permissions either - a signed-in colleague without calendar
|
||||||
|
* access passes it and is stopped by the admin layout's /me call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// better-auth prefixes the cookie with `__Secure-` when it sets Secure, which
|
||||||
|
// it does in production. Dev runs over http and gets the plain name
|
||||||
|
// (confirmed against a local API on 2026-09-28).
|
||||||
|
const SESSION_COOKIES = ["__Secure-nachklang.session_token", "nachklang.session_token"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `?redirect=` value we are willing to follow *within this app*, or the admin
|
||||||
|
* start page. Only a single-slash absolute path qualifies: "//evil.example" and
|
||||||
|
* "/\\evil.example" are read by browsers as protocol-relative URLs to another
|
||||||
|
* host. Cross-origin targets are the admin app's business - it validates them
|
||||||
|
* against its own allow-list.
|
||||||
|
*/
|
||||||
|
const internalTarget = (raw: string | null): string => {
|
||||||
|
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) {
|
||||||
|
return "/admin";
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function proxy(req: NextRequest) {
|
||||||
|
const { pathname, search } = req.nextUrl;
|
||||||
|
const signedIn = SESSION_COOKIES.some((name) => req.cookies.has(name));
|
||||||
|
|
||||||
|
// The forwarder page, which is where this redirects to. Someone who already
|
||||||
|
// has a cookie has almost certainly arrived from a bookmark; sending them on
|
||||||
|
// to the admin app would ask them to sign in again for nothing.
|
||||||
|
if (pathname === "/admin/login") {
|
||||||
|
if (!signedIn) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
return NextResponse.redirect(new URL(internalTarget(req.nextUrl.searchParams.get("redirect")), req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signedIn) {
|
||||||
|
const loginUrl = new URL("/admin/login", req.url);
|
||||||
|
loginUrl.searchParams.set("redirect", `${pathname}${search}`);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/admin/:path*"],
|
||||||
|
};
|
||||||
Whitespace-only changes.
@@ -1 +0,0 @@
|
|||||||
<router-outlet></router-outlet>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import {TestBed} from '@angular/core/testing';
|
|
||||||
import {AppComponent} from './app.component';
|
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [
|
|
||||||
AppComponent
|
|
||||||
]
|
|
||||||
}).compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create the app', () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should have as title 'nachklang-calendar'`, () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app.title).toEqual('nachklang-calendar');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render title', () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
fixture.detectChanges();
|
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(compiled.querySelector('.content span')?.textContent).toContain('nachklang-calendar app is running!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import {Component} from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
templateUrl: './app.component.html',
|
|
||||||
styleUrls: ['./app.component.css']
|
|
||||||
})
|
|
||||||
export class AppComponent {
|
|
||||||
title = 'Nachklang Calendar';
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import {NgModule} from '@angular/core';
|
|
||||||
import {BrowserModule} from '@angular/platform-browser';
|
|
||||||
|
|
||||||
import {AppComponent} from './app.component';
|
|
||||||
import {AppRouting} from './app.routing';
|
|
||||||
import {LoginComponent} from './pages/login/login.component';
|
|
||||||
import {EventComponent} from './components/event/event.component';
|
|
||||||
import {AdminComponent} from './pages/admin/admin.component';
|
|
||||||
import {LandingpageComponent} from './pages/landingpage/landingpage.component';
|
|
||||||
import {NotfoundComponent} from './pages/notfound/notfound.component';
|
|
||||||
import {ApiService} from './services/api.service';
|
|
||||||
import {HttpClientModule} from '@angular/common/http';
|
|
||||||
import {FormsModule} from '@angular/forms';
|
|
||||||
import {EventsTableComponent} from './components/events-table/events-table.component';
|
|
||||||
import { EventMovePopupComponent } from './components/event-move-popup/event-move-popup.component';
|
|
||||||
import {MAT_DIALOG_DEFAULT_OPTIONS, MatDialogModule} from "@angular/material/dialog";
|
|
||||||
import {MatFormField} from "@angular/material/form-field";
|
|
||||||
import {MatInput} from "@angular/material/input";
|
|
||||||
import {MatButton} from "@angular/material/button";
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
declarations: [
|
|
||||||
AppComponent,
|
|
||||||
LoginComponent,
|
|
||||||
EventComponent,
|
|
||||||
AdminComponent,
|
|
||||||
LandingpageComponent,
|
|
||||||
NotfoundComponent,
|
|
||||||
EventsTableComponent,
|
|
||||||
EventMovePopupComponent
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
BrowserModule,
|
|
||||||
HttpClientModule,
|
|
||||||
AppRouting,
|
|
||||||
FormsModule,
|
|
||||||
MatDialogModule,
|
|
||||||
MatFormField,
|
|
||||||
MatInput,
|
|
||||||
MatButton
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
ApiService,
|
|
||||||
{provide: MAT_DIALOG_DEFAULT_OPTIONS, useValue: {hasBackdrop: false}}
|
|
||||||
],
|
|
||||||
bootstrap: [AppComponent]
|
|
||||||
})
|
|
||||||
export class AppModule {
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import {NgModule} from '@angular/core';
|
|
||||||
import {CommonModule} from '@angular/common';
|
|
||||||
import {RouterModule, Routes} from '@angular/router';
|
|
||||||
import {LandingpageComponent} from './pages/landingpage/landingpage.component';
|
|
||||||
import {NotfoundComponent} from './pages/notfound/notfound.component';
|
|
||||||
import {AdminComponent} from './pages/admin/admin.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
{path: '', component: LandingpageComponent, pathMatch: 'full'},
|
|
||||||
{path: 'admin', component: AdminComponent},
|
|
||||||
{path: '**', component: NotfoundComponent}
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
declarations: [],
|
|
||||||
imports: [
|
|
||||||
RouterModule.forRoot(routes)
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
RouterModule
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class AppRouting {
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/* Styling for buttons */
|
|
||||||
button {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white !important;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background-color: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for dropdowns */
|
|
||||||
select {
|
|
||||||
padding: 6px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<h1 mat-dialog-title>Move {{data.event.name}}</h1>
|
|
||||||
<div mat-dialog-content>
|
|
||||||
<p>Which calendar should this event be moved to?</p>
|
|
||||||
<select [(ngModel)]="selectedCalendar" (change)="handleCalendarChange()">
|
|
||||||
<option value="" disabled selected hidden>Select calendar</option>
|
|
||||||
<option value="public">Public</option>
|
|
||||||
<option value="members">Internal - Members</option>
|
|
||||||
<option value="choir">Internal - Choir</option>
|
|
||||||
<option value="management">Internal - Management</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div mat-dialog-actions>
|
|
||||||
<button mat-button (click)="onNoClick()">Cancel</button>
|
|
||||||
<button mat-button [mat-dialog-close]="data.event" cdkFocusInitial>Move</button>
|
|
||||||
</div>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { EventMovePopupComponent } from './event-move-popup.component';
|
|
||||||
|
|
||||||
describe('EventMovePopupComponent', () => {
|
|
||||||
let component: EventMovePopupComponent;
|
|
||||||
let fixture: ComponentFixture<EventMovePopupComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [ EventMovePopupComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(EventMovePopupComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import {Component, Inject} from '@angular/core';
|
|
||||||
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
|
||||||
import {Event} from "../../models/event";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-event-move-popup',
|
|
||||||
templateUrl: './event-move-popup.component.html',
|
|
||||||
styleUrls: ['./event-move-popup.component.css']
|
|
||||||
})
|
|
||||||
export class EventMovePopupComponent {
|
|
||||||
selectedCalendar = 'public';
|
|
||||||
calendars = ['', 'public', 'members', 'management', 'choir', 'birthdays'];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public dialogRef: MatDialogRef<EventMovePopupComponent>,
|
|
||||||
@Inject(MAT_DIALOG_DATA) public data: DialogData
|
|
||||||
) {
|
|
||||||
this.selectedCalendar = this.calendars[data.event.calendarId];
|
|
||||||
}
|
|
||||||
|
|
||||||
onNoClick(): void {
|
|
||||||
this.dialogRef.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCalendarChange() {
|
|
||||||
this.data.event.calendarId = this.calendars.indexOf(this.selectedCalendar);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DialogData {
|
|
||||||
event: Event;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
p.has-error {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
td.has-error {
|
|
||||||
border-color: red;
|
|
||||||
border-width: 2px;
|
|
||||||
margin-top: 5px;
|
|
||||||
font-size: 14px;
|
|
||||||
background-color: lightcoral;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for individual event lines */
|
|
||||||
tr.app-event {
|
|
||||||
border: 1px solid black;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for input fields */
|
|
||||||
input[type="text"],
|
|
||||||
input[type="date"],
|
|
||||||
input[type="time"],
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for disabled input fields */
|
|
||||||
input:disabled {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for Save and Delete buttons */
|
|
||||||
button {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background-color: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust spacing for input and button elements */
|
|
||||||
td > input,
|
|
||||||
td > select,
|
|
||||||
td > button {
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for checkbox */
|
|
||||||
td input[type="checkbox"] {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Center align text in table cells */
|
|
||||||
td {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for readonly input fields */
|
|
||||||
td[readonly] {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<ng-container *ngIf="editActive">
|
|
||||||
<td>
|
|
||||||
{{event?.calendarId}}
|
|
||||||
</td>
|
|
||||||
<td class="{{requiredFieldsMissing? 'has-error' : ''}}">
|
|
||||||
<input type="text" [(ngModel)]="event!.name">
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input type="text" [(ngModel)]="event!.description">
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input type="checkbox" [(ngModel)]="event!.wholeDay">
|
|
||||||
</td>
|
|
||||||
<td class="{{showDateError || requiredFieldsMissing? 'has-error' : ''}}">
|
|
||||||
<input type="date" [(ngModel)]="newStartDate" (change)="setStartDateTime()">
|
|
||||||
<input type="time" [(ngModel)]="newStartTime" (change)="setStartDateTime()" *ngIf="!this.event!.wholeDay">
|
|
||||||
</td>
|
|
||||||
<td class="{{showDateError || requiredFieldsMissing? 'has-error' : ''}}">
|
|
||||||
<input type="date" [(ngModel)]="newEndDate" (change)="setEndDateTime()">
|
|
||||||
<input type="time" [(ngModel)]="newEndTime" (change)="setEndDateTime()" *ngIf="!this.event!.wholeDay">
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input type="text" [(ngModel)]="event!.location">
|
|
||||||
</td>
|
|
||||||
<td class="{{invalidUrlError? 'has-error' : ''}}">
|
|
||||||
<input type="text" [(ngModel)]="event!.url">
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
By: {{event?.createdBy}}
|
|
||||||
<br>
|
|
||||||
At: {{printDate(event?.createdDate!)}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
By: {{event?.lastModifiedBy}}
|
|
||||||
<br>
|
|
||||||
At: {{printDate(event?.lastModifiedDate!)}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select [(ngModel)]="event!.status">
|
|
||||||
<option value="DRAFT">Draft</option>
|
|
||||||
<option value="PUBLIC">Published</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button (click)="toggleEdit()">Save</button>
|
|
||||||
<p class="has-error" *ngIf="showDateError">Start Date must not be after end date!</p>
|
|
||||||
<p class="has-error" *ngIf="requiredFieldsMissing">Required fields are missing!</p>
|
|
||||||
<p class="has-error" *ngIf="showCreateError">Error creating event. Please try again.</p>
|
|
||||||
<p class="has-error" *ngIf="invalidUrlError">Invalid URL, please only provide valid ones!</p>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button (click)="triggerMove()" *ngIf="event!.eventId !== undefined">Move</button>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button (click)="triggerDelete()" *ngIf="event!.eventId !== undefined">Delete</button>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="!editActive">
|
|
||||||
<td>
|
|
||||||
{{event?.calendarId}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{event?.name}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{event?.description}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input type="checkbox" [(ngModel)]="event!.wholeDay" disabled>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input type="date" [value]="newStartDate" readonly>
|
|
||||||
<input type="time" [value]="newStartTime" readonly *ngIf="!this.event!.wholeDay">
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input type="date" [value]="newEndDate" readonly>
|
|
||||||
<input type="time" [value]="newEndTime" readonly *ngIf="!this.event!.wholeDay">
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{event?.location}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{event?.url}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
By: {{event?.createdBy}}
|
|
||||||
<br>
|
|
||||||
At: {{printDate(event?.createdDate!)}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
By: {{event?.lastModifiedBy}}
|
|
||||||
<br>
|
|
||||||
At: {{printDate(event?.lastModifiedDate!)}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{getEventStatusFriendlyName()}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button (click)="toggleEdit()">Edit</button>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button (click)="triggerMove()">Move</button>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button (click)="triggerDelete()">Delete</button>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
|
||||||
|
|
||||||
import {EventComponent} from './event.component';
|
|
||||||
|
|
||||||
describe('EventComponent', () => {
|
|
||||||
let component: EventComponent;
|
|
||||||
let fixture: ComponentFixture<EventComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [EventComponent]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(EventComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core';
|
|
||||||
import {Subject} from 'rxjs';
|
|
||||||
import {takeUntil} from 'rxjs/operators';
|
|
||||||
import {MatDialog} from '@angular/material/dialog';
|
|
||||||
import {Event} from '../../models/event';
|
|
||||||
import {HttpErrorResponse} from '@angular/common/http';
|
|
||||||
import {ApiService} from '../../services/api.service';
|
|
||||||
import {AdminAuthService} from '../../services/admin-auth.service';
|
|
||||||
import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.component";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: '[app-event]',
|
|
||||||
templateUrl: './event.component.html',
|
|
||||||
styleUrls: ['./event.component.css']
|
|
||||||
})
|
|
||||||
export class EventComponent implements OnInit, OnDestroy {
|
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
|
||||||
|
|
||||||
@Input() event: Event | undefined;
|
|
||||||
@Input() editActive: boolean = false;
|
|
||||||
newStartDate: string | undefined;
|
|
||||||
newStartTime: string | undefined;
|
|
||||||
newEndDate: string | undefined;
|
|
||||||
newEndTime: string | undefined;
|
|
||||||
showDateError: boolean = false;
|
|
||||||
invalidUrlError: boolean = false;
|
|
||||||
requiredFieldsMissing: boolean = false;
|
|
||||||
showCreateError: boolean = false;
|
|
||||||
@Output() deleteEvent = new EventEmitter<number>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private api: ApiService,
|
|
||||||
public dialog: MatDialog
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
if (this.event) {
|
|
||||||
this.newStartDate = this.serializeDate(this.event.startDateTime);
|
|
||||||
this.newStartTime = this.serializeTime(this.event.startDateTime);
|
|
||||||
this.newEndDate = this.serializeDate(this.event.endDateTime);
|
|
||||||
this.newEndTime = this.serializeTime(this.event.endDateTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleEdit() {
|
|
||||||
if (this.editActive && this.event !== undefined) {
|
|
||||||
// Prevent save if endDateTime is before startDateTime
|
|
||||||
if(this.event.endDateTime < this.event.startDateTime) {
|
|
||||||
this.showDateError = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check required fields
|
|
||||||
if(
|
|
||||||
this.isNullOrBlank(this.event.name) ||
|
|
||||||
this.event.startDateTime == undefined ||
|
|
||||||
this.event.endDateTime == undefined
|
|
||||||
) {
|
|
||||||
this.requiredFieldsMissing = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!this.checkEventValidUrl()) {
|
|
||||||
this.invalidUrlError = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(this.event.eventId === undefined) {
|
|
||||||
this.api.createEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
if(res.eventId) {
|
|
||||||
this.event!.eventId = res.eventId;
|
|
||||||
} else {
|
|
||||||
this.showCreateError = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: this.handleWriteError('The new event')
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Update existing event
|
|
||||||
this.api.updateEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
|
|
||||||
next: () => {},
|
|
||||||
error: this.handleWriteError('Your change')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.editActive = !this.editActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStartDateTime() {
|
|
||||||
if (this.newStartDate && this.event) {
|
|
||||||
let newDate: Date = this.parseDate(this.newStartDate);
|
|
||||||
|
|
||||||
this.event.startDateTime.setFullYear(newDate.getFullYear());
|
|
||||||
this.event.startDateTime.setMonth(newDate.getMonth());
|
|
||||||
this.event.startDateTime.setDate(newDate.getDate());
|
|
||||||
}
|
|
||||||
if (this.newStartTime && this.event) {
|
|
||||||
let newTime: Date = this.parseTime(this.newStartTime);
|
|
||||||
|
|
||||||
this.event.startDateTime.setHours(newTime.getHours());
|
|
||||||
this.event.startDateTime.setMinutes(newTime.getMinutes());
|
|
||||||
this.event.startDateTime.setSeconds(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set end date accordingly if behind start date
|
|
||||||
if(this.event) {
|
|
||||||
if(this.event.endDateTime < this.event.startDateTime) {
|
|
||||||
this.newEndDate = this.serializeDate(this.event.startDateTime);
|
|
||||||
this.newEndTime = this.serializeTime(this.event.startDateTime);
|
|
||||||
this.setEndDateTime();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setEndDateTime() {
|
|
||||||
if (this.newEndDate && this.event) {
|
|
||||||
let newDate: Date = this.parseDate(this.newEndDate);
|
|
||||||
|
|
||||||
this.event.endDateTime.setFullYear(newDate.getFullYear());
|
|
||||||
this.event.endDateTime.setMonth(newDate.getMonth());
|
|
||||||
this.event.endDateTime.setDate(newDate.getDate());
|
|
||||||
}
|
|
||||||
if (this.newEndTime && this.event) {
|
|
||||||
let newTime: Date = this.parseTime(this.newEndTime);
|
|
||||||
|
|
||||||
this.event.endDateTime.setHours(newTime.getHours());
|
|
||||||
this.event.endDateTime.setMinutes(newTime.getMinutes());
|
|
||||||
this.event.endDateTime.setSeconds(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
serializeDate(date: Date): string {
|
|
||||||
let year = date.getFullYear();
|
|
||||||
let month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
||||||
let day = date.getDate().toString().padStart(2, '0');
|
|
||||||
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
serializeTime(date: Date): string {
|
|
||||||
let hours = date.getHours().toString().padStart(2, '0');
|
|
||||||
let minutes = date.getMinutes().toString().padStart(2, '0');
|
|
||||||
|
|
||||||
return `${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
printDate(date: Date): string {
|
|
||||||
try {
|
|
||||||
let year = date.getFullYear();
|
|
||||||
let month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
||||||
let day = date.getDate().toString().padStart(2, '0');
|
|
||||||
let hours = date.getHours().toString().padStart(2, '0');
|
|
||||||
let minutes = date.getMinutes().toString().padStart(2, '0');
|
|
||||||
|
|
||||||
return `${day}.${month}.${year}, ${hours}:${minutes}`;
|
|
||||||
} catch (e) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parseDate(text: string): Date {
|
|
||||||
return new Date(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
parseTime(text: string): Date {
|
|
||||||
return new Date(`2000-01-01T${text}:00`);
|
|
||||||
}
|
|
||||||
|
|
||||||
isNullOrBlank(str: string | null): boolean {
|
|
||||||
return str === null || str === undefined || str.trim() === '';
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerDelete() {
|
|
||||||
let deleteConfirmed = window.confirm(`Are you sure you want to delete "${this.event!.name}"? This action cannot be undone.`);
|
|
||||||
|
|
||||||
if(deleteConfirmed && this.event) {
|
|
||||||
this.api.deleteEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
if(res.message) {
|
|
||||||
this.deleteEvent.next(this.event!.eventId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: this.handleWriteError('The deletion')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What to do when a write fails.
|
|
||||||
*
|
|
||||||
* Before the auth cutover none of the writes here had an error callback, so
|
|
||||||
* a failure was invisible: the row closed, nothing was saved, and the user
|
|
||||||
* had every reason to think it had been. A 401 is now a routine event - the
|
|
||||||
* session expires, or is ended from another app or another tab - so silence
|
|
||||||
* is no longer survivable.
|
|
||||||
*
|
|
||||||
* A 401 means the session is gone, and nothing on this page can be saved
|
|
||||||
* until it comes back, so it goes straight to the login carrying this page
|
|
||||||
* as the return target. Everything else says what happened and leaves the
|
|
||||||
* user where they are, with their edits still on screen.
|
|
||||||
*/
|
|
||||||
private handleWriteError(action: string): (error: HttpErrorResponse) => void {
|
|
||||||
return (error: HttpErrorResponse) => {
|
|
||||||
if (error.status === 401) {
|
|
||||||
window.alert(`Your session has expired, so ${action} was not saved. Signing you in again.`);
|
|
||||||
AdminAuthService.goToLogin();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error.status === 403) {
|
|
||||||
window.alert(`${action} failed: this account no longer has access to the calendar.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.alert(`${action} failed. Please try again. (${error.status || 'no response from the server'})`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerMove() {
|
|
||||||
if(this.editActive) {
|
|
||||||
window.alert('Please save your changes before moving the event to a different calendar.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let movePopup = this.dialog.open(EventMovePopupComponent, {
|
|
||||||
data: {
|
|
||||||
event: this.event
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
movePopup.afterClosed().pipe(takeUntil(this.destroy$)).subscribe(result => {
|
|
||||||
// If popup is dismissed, undefined will be returned
|
|
||||||
if(result) {
|
|
||||||
this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe({
|
|
||||||
next: () => {
|
|
||||||
// Uses the same interface as delete as from the calendar table perspective it is the same action
|
|
||||||
// as a delete
|
|
||||||
this.deleteEvent.next(result.eventId);
|
|
||||||
},
|
|
||||||
error: this.handleWriteError('The move')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
checkEventValidUrl(): boolean {
|
|
||||||
if(this.isNullOrBlank(this.event!.url)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlRegex = /^(https?:\/\/)?((www\.)?([a-z0-9äöü]*\.)+[a-z]{2,10}.*)$/i;
|
|
||||||
|
|
||||||
let validUrl = urlRegex.test(this.event!.url);
|
|
||||||
|
|
||||||
if(validUrl){
|
|
||||||
let match = this.event!.url.match(urlRegex);
|
|
||||||
if(match) {
|
|
||||||
this.event!.url = 'https://' + match[2];
|
|
||||||
} else {
|
|
||||||
validUrl = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return validUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
getEventStatusFriendlyName(): string {
|
|
||||||
switch (this.event!.status) {
|
|
||||||
case 'DRAFT':
|
|
||||||
return 'Draft';
|
|
||||||
case 'PUBLIC':
|
|
||||||
return 'Published';
|
|
||||||
default:
|
|
||||||
return 'Error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/* Styling for the whole table */
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 20px;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for table header cells */
|
|
||||||
th {
|
|
||||||
background-color: #f2f2f2;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: left;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for table data cells */
|
|
||||||
td {
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add some hover effect to rows */
|
|
||||||
tr:hover {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for the "Add Event" button */
|
|
||||||
button {
|
|
||||||
padding: 10px 15px;
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background-color: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for individual event lines */
|
|
||||||
tr:nth-child(odd) {
|
|
||||||
background-color: #f2f2f2; /* Light gray for odd rows */
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:nth-child(even) {
|
|
||||||
background-color: #ffffff; /* White for even rows */
|
|
||||||
}
|
|
||||||
|
|
||||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user