Rewrite the calendar frontend in Next.js

Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn,
the same stack and design system as nachklang-feedback, planned in
docs/rewrite-plan.md.

- Admin: all calendars in one mobile-friendly list (month groups, calendar
  chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit
  sheet for create/edit/duplicate/delete, publish confirmation, and an
  "Abonnieren" dialog with subscription links for every calendar.
- Public landing page: subscribe links from env instead of hardcoded, plus
  the next public events, server-rendered and revalidated every 5 minutes.
- All times handled explicitly as Europe/Berlin; whole-day events stored as
  00:00-23:59. Fixes carried over from the Angular app: the move dialog
  leaking a cancelled calendar change, saves that looked saved when they
  failed, in-place Date mutation, and duplicated calendar-id mappings.
- Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403).
- 112 Vitest tests over lib/; docs in docs/ (architecture, features,
  gotchas, API wish list, deployment).

Needs the API branch feature/calendar-ical-escaping-subscriptions deployed
first (GET /calendar/events/subscriptions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
PaddyandClaude Opus 5.5 committed 2026-09-28 22:15:59 +02:00
1 parent f82255961b
commit 0cda40f85e
138 files changed
+15943 -24011

No files matched your search

-16
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "nachklang-calendar-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 4200
}
]
}
-16
View File
@@ -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
+11
View File
@@ -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
View File
@@ -1,43 +1,42 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
# dependencies
/node_modules
npm-debug.log
yarn-error.log
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# IDEs and editors
.idea/
.vscode/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
# testing
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
Thumbs.db
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.local.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+1
View File
@@ -0,0 +1 @@
26
+5
View File
@@ -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 -->
+54 -38
View File
@@ -2,24 +2,56 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
## Commands
```bash
npm start # Dev server at http://localhost:4200/ (live reload)
npm run build # Production build
npm run watch # Build with watch mode (development config)
npm test # Run unit tests (Jasmine/Karma in Chrome)
npm run dev # Dev server at http://localhost:4200/ - the port is required, see Authentication
npm run build # Production build (output: standalone; postbuild copies .next/static in)
npm run lint # ESLint (eslint-config-next)
npm test # Vitest, pure logic in lib/ (runs with TZ=America/New_York on purpose)
```
No linter is configured in this project.
`NEXT_PUBLIC_API_BASE_URL` and `NEXT_PUBLIC_ADMIN_APP_URL` must be set (`.env.local`, from
`.env.local.example`); `lib/env.ts` fails the build without them.
## Docs
`docs/` is kept current as part of every change - features in `features.md`, surprises in
`gotchas.md`, API requests in `api-wishlist.md`.
- [docs/architecture.md](docs/architecture.md) - routes, auth, data flow, time handling
- [docs/features.md](docs/features.md) - what the app does
- [docs/gotchas.md](docs/gotchas.md) - read before assuming how the API, Next or Base UI behaves
- [docs/api-wishlist.md](docs/api-wishlist.md) - API changes this app would like
- [docs/deployment.md](docs/deployment.md) - Plesk build/run, switchover, rollback
- [docs/rewrite-plan.md](docs/rewrite-plan.md) - the Angular → Next rewrite: decisions and progress log
## Architecture
Angular 18 single-page app for managing calendar events for the "Nachklang" organization. Uses Angular Material for UI, RxJS for async data, and reactive forms. No NgRx — state lives in component local variables.
Next.js 16 (App Router) + React 19 + TypeScript + Tailwind 4 + shadcn (Base UI primitives), the same
stack and design system as `nachklang-feedback`. Rewritten from Angular in September 2026. German UI.
**Environments** (`src/environments/`): `apiUrl` and `adminAppUrl`.
- Dev: `http://localhost:3000` (expects backend running locally) / `http://localhost:3002`
- Prod: `https://api.nachklang.art` / `https://admin.nachklang.art`
- `app/page.tsx` - public landing page (server component, `revalidate = 300`).
- `app/admin/` - the Verwaltung: `layout.tsx` is the session gate, `page.tsx` the event list,
`login/page.tsx` a forwarder to the admin app.
- `components/admin/` - list, toolbar, chips, `EventSheet` (create/edit/duplicate/delete) and
`EventForm`, `PublishDialog`, `SubscriptionsDialog`, hooks `useEvents` / `useListState`.
- `lib/` - all logic that isn't rendering, and all of it is unit-tested: `api.ts`, `dates.ts`
(Berlin time), `event-form.ts` (form rules, validation, payloads), `filter.ts`, `url-state.ts`,
`calendars.ts` (the one calendar list), `events.ts` (parsing the API's raw rows).
- `proxy.ts` - cookie-presence gate on `/admin/*`.
Rules worth knowing before editing (details in `docs/`):
- **Every wall-clock time is Europe/Berlin**, whatever the browser's zone. Forms hold strings, the API
gets UTC instants. Whole-day events are 00:00-23:59 Berlin, end inclusive.
- **Always send the whole event.** The API's PUT defaults missing fields (`status` → `PUBLIC`,
`repeatFrequency` → `''`).
- **Calendars live in `lib/calendars.ts`** and must match `calendarNames` in the API.
- **After `npx shadcn add`**, point `cn` imports back at `@/lib/utils` and drop the `cn` /
`next-themes` packages it adds (see `docs/gotchas.md`).
## Authentication
@@ -29,44 +61,28 @@ feedback and admin apps: accounts live in the admin app, and the session is an h
cookie on `.nachklang.art` that the *API's* host sets. Consequences that cannot be designed
around:
- `withCredentials: true` is mandatory on every call (`api.service.ts` sets it once). Without
it the browser sends no cookie and the API answers 401.
- `credentials: "include"` is mandatory on every call (`lib/api.ts` sets it once). Without it
the browser sends no cookie and the API answers 401.
- This app can never read or verify the session. It calls `GET /admin/me` and believes the
answer; the API's `requireAppAccess('calendar')` is the actual gate.
answer; the API's `requireAppAccess('calendar')` is the actual gate. `/admin/me` answers 200
for an account without the calendar permission - check `apps`.
- 401 and 403 mean different things and must not be collapsed. 401 means "nobody is signed
in" and is the only one worth redirecting to the login page - redirecting on 403 produces a
loop where signing in succeeds and lands straight back on the refusal. See `failure` in
`admin.component.ts`.
loop where signing in succeeds and lands straight back on the refusal. See `app/admin/layout.tsx`.
- Sign-out ends the session for *all four* apps; there is only one.
The dev server must be reachable at a port the API trusts. `ng serve` defaults to 4200, which
The dev server must be reachable at a port the API trusts. `npm run dev` pins 4200 (`next dev -p 4200`), which
is in better-auth's dev `localhostOrigins` and in the admin app's
`NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS`; another port fails sign-out and the return redirect,
not the sign-in.
**The public calendar stays anonymous.** `GET /calendar/events/public/json` needs no session
at all, because nachklang.art reads it to show the next upcoming event.
at all, because nachklang.art reads it to show the next upcoming event - and so does this app's
landing page, server-side.
**Routing** (`app.routing.ts`):
- `/` → `LandingpageComponent`
- `/admin` → `AdminComponent` (main calendar management page)
- `**` → `NotfoundComponent`
## Local testing
**Service layer** (`src/app/services/`):
- `api.service.ts` — all HTTP calls to the backend REST API; carries the session cookie via `withCredentials`, holds no credential itself
- `admin-auth.service.ts` — where signing in happens: the admin app's login URL (with a `?redirect=` back here) and sign-out
- `utils.service.ts` — caches the signed-in user's display name for unsaved draft rows. Cosmetic only; the server takes the author from the session
**Data models** (`src/app/models/`): `Event`, `User`, `Session`
**Admin page** (`src/app/pages/admin/`) is the core of the app. It owns events state and passes it down to `EventsTableComponent` via `@Input()`. Key features: multi-calendar support (public, members, choir, management, birthdays), event filtering (future/past/all), sorting, and an event-move dialog (`EventMovePopupComponent` via Angular Material Dialog).
**Calendar-specific behavior:** Birthday calendar auto-sets recurrence to YEARLY. Events have a `status` field (`DRAFT` / `DELETED`).
**Session lifecycle:** `AdminComponent` calls `me()` on init. 401 redirects to the admin app's
login carrying this URL as the return target; 403 (or a signed-in account without the
`calendar` permission) shows a refusal with Reload/Sign out; anything else shows "cannot be
reached". None of those three redirect, on purpose.
**Dead since the cutover:** the unrouted `LoginComponent`. `src/app/models/session.ts` is
gone; there is no session type here any more, because this app never handles one.
Use a throwaway database, never shared infrastructure: the API repo's `docker/init` scripts seed a
MariaDB container (podman works) including the `dev@nachklang.art` account; start the API from a
scratch directory with its own `.env`. The exact commands are in the progress log of
`docs/rewrite-plan.md` ("Local test environment").
+21 -17
View File
@@ -1,27 +1,31 @@
# Nachklang Calendar
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 14.2.1.
Event calendar for Nachklang e.V.: a public landing page for subscribing to the calendar, and an
admin area (`/admin`) for editing events in all calendars.
## Development server
Next.js (App Router) + TypeScript + Tailwind + shadcn, the same stack as `nachklang-feedback`.
Rewritten from Angular in September 2026 ([docs/rewrite-plan.md](docs/rewrite-plan.md)).
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
Documentation: [architecture](docs/architecture.md) · [features](docs/features.md) ·
[gotchas](docs/gotchas.md) · [deployment](docs/deployment.md) · [API wish list](docs/api-wishlist.md)
## Code scaffolding
## Development
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
```bash
cp .env.local.example .env.local # then adjust if needed
npm install
npm run dev # http://localhost:4200
```
## Build
Port 4200 is not arbitrary: it is the port the API's better-auth origins and the admin app's
redirect allow-list trust. Another port breaks sign-out and the return redirect after sign-in.
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
The admin area needs the API (`:3000`) and the admin app (`:3002`) running locally.
## Running unit tests
## Other scripts
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
```bash
npm run build # production build (output: standalone; see docs/deployment.md)
npm run lint
npm test # Vitest, pure logic in lib/
```
-104
View File
@@ -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
}
}
+139
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+221
View File
@@ -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>
);
}
View File
File renamed without changes.
+173
View File
@@ -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;
}
}
+35
View File
@@ -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
View File
@@ -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>
);
}
+25
View File
@@ -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": {}
}
+21
View File
@@ -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;
};
+19
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+258
View File
@@ -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>
);
}
+147
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+376
View File
@@ -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>
);
}
+151
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+151
View File
@@ -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>
);
}
+82
View File
@@ -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 };
}
+31
View File
@@ -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];
}
+16
View File
@@ -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,
);
}
+45
View File
@@ -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>
);
}
+187
View File
@@ -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,
}
+51
View File
@@ -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 }
+55
View File
@@ -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 }
+160
View File
@@ -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,
}
+267
View File
@@ -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,
}
+238
View File
@@ -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,
}
+19
View File
@@ -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 }
+19
View File
@@ -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 }
+203
View File
@@ -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,
}
+24
View File
@@ -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 }
+138
View File
@@ -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,
}
+13
View File
@@ -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 }
+48
View File
@@ -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 }
+31
View File
@@ -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 }
+17
View File
@@ -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 }
+89
View File
@@ -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 }
+44
View File
@@ -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 }
+33
View File
@@ -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).
+99
View File
@@ -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).
+69
View File
@@ -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.
+49
View File
@@ -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.
+71
View File
@@ -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.
+423
View File
@@ -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 `&nbsp;|&nbsp;`.
- 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.
+18
View File
@@ -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;
-44
View File
@@ -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
});
};
+67
View File
@@ -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);
};
+31
View File
@@ -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;
+86
View File
@@ -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
View File
@@ -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" });
},
};
+82
View File
@@ -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);
+134
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+107
View File
@@ -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);
});
});
+92
View File
@@ -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();
}
};
+222
View File
@@ -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 });
});
});
+269
View File
@@ -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,
});
+72
View File
@@ -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]);
});
});
+62
View File
@@ -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);
+107
View File
@@ -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
View File
@@ -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;
};
+85
View File
@@ -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 } });
});
});
+65
View File
@@ -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;
}
};
+23
View File
@@ -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,
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Shapes on the wire and in the app.
*
* `ApiEvent` is what `GET /calendar/events/:calendar/json` actually returns,
* which is looser than the API's own `Event` interface claims: the service maps
* raw MariaDB rows straight through (`toEvent` in events.service.ts), so
* `wholeDay` arrives as whatever the driver produced for the column (1/0 rather
* than true/false is the likely case), and a deleted event's latest version is
* a row with only `status` set - every other version column is null. `status`
* is missing entirely from the anonymous public listing.
*
* `CalendarEvent` is the parsed, trustworthy form everything else in the app
* works with. Parsing happens once, in `parseApiEvent`.
*/
export type EventStatus = "PUBLIC" | "PRIVATE" | "DRAFT" | "DELETED";
export interface ApiEvent {
eventId: number;
calendarId: number;
uuid: string;
name: string | null;
description: string | null;
startDateTime: string | null;
endDateTime: string | null;
createdDate: string | null;
lastModifiedDate?: string | null;
location: string | null;
createdBy?: string | null;
lastModifiedBy?: string | null;
url: string | null;
wholeDay: boolean | number | null;
repeatFrequency: string | null;
status?: string | null;
}
export interface CalendarEvent {
eventId: number;
calendarId: number;
uuid: string;
name: string;
description: string;
/** Instants. Wall-clock meaning is always Europe/Berlin, see lib/dates.ts. */
start: Date;
end: Date;
created: Date | null;
lastModified: Date | null;
location: string;
createdBy: string;
lastModifiedBy: string;
url: string;
wholeDay: boolean;
/** "" when the event does not repeat. Sent to iCal verbatim as RRULE:FREQ=… */
repeatFrequency: string;
status: EventStatus;
}
/**
* The body of POST /calendar/events, PUT /calendar/events/:id and
* PUT /calendar/events/move/:id.
*
* Every field is required here on purpose. PUT replaces the whole version and
* defaults whatever is missing - `repeatFrequency ?? ''`, `status ?? 'PUBLIC'`
* (events.router.ts) - so a partial body silently drops a birthday's
* recurrence or publishes a draft.
*/
export interface EventPayload {
calendarId: number;
name: string;
description: string;
startDateTime: string;
endDateTime: string;
location: string;
url: string;
wholeDay: boolean;
repeatFrequency: string;
status: EventStatus;
}
/** GET /admin/me - the signed-in account, shared by all Nachklang apps. */
export interface AdminUser {
id: string;
email: string;
fullName: string;
apps: string[];
}
+76
View File
@@ -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" });
});
});
+111
View File
@@ -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, ",")}`);
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+25
View File
@@ -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;
+8694 -21591
View File
File diff suppressed because it is too large. Load diff
+31 -32
View File
@@ -1,40 +1,39 @@
{
"name": "nachklang-calendar",
"version": "1.1.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"version": "2.0.0",
"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": {
"@angular/animations": "^18.0.1",
"@angular/common": "^18.0.1",
"@angular/compiler": "^18.0.1",
"@angular/core": "^18.0.1",
"@angular/forms": "^18.0.1",
"@angular/material": "^18.0.1",
"@angular/platform-browser": "^18.0.1",
"@angular/platform-browser-dynamic": "^18.0.1",
"@angular/router": "^18.0.1",
"@material/dialog": "^15.0.0-canary.7f224ddd4.0",
"rxjs": "~7.8.1",
"tslib": "^2.6.2",
"zone.js": "~0.14.6"
"@base-ui/react": "^1.7.0",
"@date-fns/tz": "^1.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.33.0",
"next": "16.2.12",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.19.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.2",
"@angular/cli": "~18.0.2",
"@angular/compiler-cli": "^18.0.1",
"@types/jasmine": "~5.1.4",
"jasmine-core": "~5.1.2",
"karma": "~6.4.3",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.4.5"
"@tailwindcss/postcss": "^4",
"@types/node": "^26",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.12",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^5.0.2"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+65
View File
@@ -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*"],
};
View File
Whitespace-only changes.
-1
View File
@@ -1 +0,0 @@
<router-outlet></router-outlet>
-31
View File
@@ -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!');
});
});
-10
View File
@@ -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';
}
-49
View File
@@ -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 {
}
-24
View File
@@ -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();
});
});
-286
View File
@@ -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