From c39599bb615091a61c6714817be0a67028cf3664 Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Fri, 11 Sep 2026 15:47:44 +0200 Subject: [PATCH] feat: Aus der App wird ein Wegweiser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diese Adresse führte bis September 2026 eine Anwendung: Zeitnahme, Kader, Trails, Renntage. Die heißt jetzt Kurbeler, liegt unter kurbeler.de und führt den Stammtisch als eines von mehreren Teams. Was hier bleibt, ist die Adresse — und die soll niemanden ins Leere laufen lassen. Übrig sind drei vorgerenderte Seiten: Wappen, ein Satz, ein Knopf, dazu Impressum und Datenschutz. Kein Konto, keine Datenbank, kein Formular, keine Cookies, keine Schriften oder Karten von fremden Servern. Die Datenschutzerklärung ist deshalb kurz und nicht die von Kurbeler mit Streichungen — eine Erklärung, die von Konten und Benachrichtigungen spricht, wäre auf dieser Seite schlicht falsch. `adapter-static` statt `adapter-node`, ausgeliefert von nginx: Für drei Seiten ohne eine dynamische Angabe einen JavaScript-Prozess laufen zu lassen, der auf Anfragen wartet, um jedes Mal dieselbe Datei herauszugeben, wäre Beschäftigung ohne Arbeit. Von den Abhängigkeiten bleiben die zwölf, die zum Bauen nötig sind; zur Laufzeit keine einzige. Das Bild ist damit 492 kB statt elf Megabyte — zwei alte Grafiken von je 2,4 MB lagen ungenutzt in `static/`. Das Backend bleibt unangetastet. Es wird vom Wegweiser nicht benutzt, aber darin liegen die Daten des alten Deployments. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LTw6xVYgjMy9AQtfs1Gdyu --- CLAUDE.md | 193 +- README.md | 76 +- TODO.md | 0 UML_basic.png | Bin 131733 -> 0 bytes frontend/.dockerignore | 10 + frontend/Dockerfile | 31 + frontend/bun.lock | 326 + frontend/docker-compose.yaml | 17 + frontend/nginx.conf | 29 + frontend/package-lock.json | 6341 ----------------- frontend/package.json | 28 +- frontend/scripts/schema-to-json.mjs | 52 - frontend/src/app.html | 6 +- frontend/src/lib/assets/favicon.svg | 1 - frontend/src/lib/components/Avatar.svelte | 78 - .../src/lib/components/ClockSyncDialog.svelte | 120 - .../src/lib/components/ConfirmDialog.svelte | 36 - .../lib/components/CreateTeamDialog.svelte | 76 - .../lib/components/ElevationProfile.svelte | 217 - frontend/src/lib/components/FlagIcon.svelte | 34 - frontend/src/lib/components/IconPicker.svelte | 54 - frontend/src/lib/components/LegalPage.svelte | 41 + frontend/src/lib/components/Logo.svelte | 28 - .../lib/components/RunningTimesToast.svelte | 243 - frontend/src/lib/components/Spinner.svelte | 39 - frontend/src/lib/components/TeamMenu.svelte | 101 - frontend/src/lib/components/TimeDialog.svelte | 281 - frontend/src/lib/components/TrailMap.svelte | 494 -- .../ui/accordion/accordion-content.svelte | 22 - .../ui/accordion/accordion-item.svelte | 17 - .../ui/accordion/accordion-trigger.svelte | 32 - .../components/ui/accordion/accordion.svelte | 16 - .../src/lib/components/ui/accordion/index.ts | 16 - .../src/lib/components/ui/badge/badge.svelte | 49 - frontend/src/lib/components/ui/badge/index.ts | 2 - .../lib/components/ui/button/button.svelte | 95 - .../src/lib/components/ui/button/index.ts | 17 - .../lib/components/ui/card/card-action.svelte | 23 - .../components/ui/card/card-content.svelte | 20 - .../ui/card/card-description.svelte | 20 - .../lib/components/ui/card/card-footer.svelte | 20 - .../lib/components/ui/card/card-header.svelte | 23 - .../lib/components/ui/card/card-title.svelte | 20 - .../src/lib/components/ui/card/card.svelte | 22 - frontend/src/lib/components/ui/card/index.ts | 25 - .../components/ui/dialog/dialog-close.svelte | 11 - .../ui/dialog/dialog-content.svelte | 48 - .../ui/dialog/dialog-description.svelte | 17 - .../components/ui/dialog/dialog-footer.svelte | 32 - .../components/ui/dialog/dialog-header.svelte | 20 - .../ui/dialog/dialog-overlay.svelte | 17 - .../components/ui/dialog/dialog-portal.svelte | 7 - .../components/ui/dialog/dialog-title.svelte | 17 - .../ui/dialog/dialog-trigger.svelte | 11 - .../lib/components/ui/dialog/dialog.svelte | 7 - .../src/lib/components/ui/dialog/index.ts | 34 - .../dropdown-menu-checkbox-group.svelte | 16 - .../dropdown-menu-checkbox-item.svelte | 44 - .../dropdown-menu-content.svelte | 31 - .../dropdown-menu-group-heading.svelte | 22 - .../dropdown-menu/dropdown-menu-group.svelte | 7 - .../dropdown-menu/dropdown-menu-item.svelte | 27 - .../dropdown-menu/dropdown-menu-label.svelte | 24 - .../dropdown-menu/dropdown-menu-portal.svelte | 7 - .../dropdown-menu-radio-group.svelte | 16 - .../dropdown-menu-radio-item.svelte | 34 - .../dropdown-menu-separator.svelte | 17 - .../dropdown-menu-shortcut.svelte | 20 - .../dropdown-menu-sub-content.svelte | 17 - .../dropdown-menu-sub-trigger.svelte | 29 - .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 - .../dropdown-menu-trigger.svelte | 7 - .../ui/dropdown-menu/dropdown-menu.svelte | 7 - .../lib/components/ui/dropdown-menu/index.ts | 54 - frontend/src/lib/components/ui/input/index.ts | 7 - .../src/lib/components/ui/input/input.svelte | 48 - frontend/src/lib/components/ui/label/index.ts | 7 - .../src/lib/components/ui/label/label.svelte | 20 - .../components/ui/navigation-menu/index.ts | 28 - .../navigation-menu-content.svelte | 20 - .../navigation-menu-indicator.svelte | 22 - .../navigation-menu-item.svelte | 17 - .../navigation-menu-link.svelte | 17 - .../navigation-menu-list.svelte | 20 - .../navigation-menu-trigger.svelte | 29 - .../navigation-menu-viewport.svelte | 22 - .../ui/navigation-menu/navigation-menu.svelte | 31 - .../src/lib/components/ui/select/index.ts | 37 - .../ui/select/select-content.svelte | 45 - .../ui/select/select-group-heading.svelte | 21 - .../components/ui/select/select-group.svelte | 17 - .../components/ui/select/select-item.svelte | 38 - .../components/ui/select/select-label.svelte | 20 - .../components/ui/select/select-portal.svelte | 7 - .../select/select-scroll-down-button.svelte | 20 - .../ui/select/select-scroll-up-button.svelte | 20 - .../ui/select/select-separator.svelte | 18 - .../ui/select/select-trigger.svelte | 29 - .../lib/components/ui/select/select.svelte | 11 - .../src/lib/components/ui/separator/index.ts | 7 - .../components/ui/separator/separator.svelte | 23 - frontend/src/lib/components/ui/table/index.ts | 28 - .../lib/components/ui/table/table-body.svelte | 15 - .../components/ui/table/table-caption.svelte | 20 - .../lib/components/ui/table/table-cell.svelte | 15 - .../components/ui/table/table-footer.svelte | 20 - .../lib/components/ui/table/table-head.svelte | 15 - .../components/ui/table/table-header.svelte | 20 - .../lib/components/ui/table/table-row.svelte | 15 - .../src/lib/components/ui/table/table.svelte | 17 - frontend/src/lib/flagIcons.ts | 48 - frontend/src/lib/gpx.test.ts | 291 - frontend/src/lib/gpx.ts | 333 - frontend/src/lib/index.ts | 1 - frontend/src/lib/stores/app.svelte.ts | 205 - frontend/src/lib/stores/clockSync.svelte.ts | 171 - frontend/src/lib/stores/connection.svelte.ts | 141 - .../lib/stores/eventParticipants.svelte.ts | 183 - frontend/src/lib/stores/eventSeries.svelte.ts | 183 - frontend/src/lib/stores/eventSeries.test.ts | 50 - frontend/src/lib/stores/events.svelte.ts | 166 - frontend/src/lib/stores/pocketbase.svelte.ts | 180 - frontend/src/lib/stores/riders.svelte.ts | 211 - frontend/src/lib/stores/stages.svelte.ts | 108 - frontend/src/lib/stores/teamInvites.svelte.ts | 193 - frontend/src/lib/stores/teams.svelte.ts | 265 - frontend/src/lib/stores/ticker.svelte.ts | 22 - frontend/src/lib/stores/timeOutbox.svelte.ts | 204 - frontend/src/lib/stores/times.svelte.ts | 357 - .../src/lib/stores/trailComments.svelte.ts | 104 - frontend/src/lib/stores/trailFlags.svelte.ts | 157 - .../src/lib/stores/trailMarkers.svelte.ts | 158 - .../src/lib/stores/trailVersions.svelte.ts | 121 - frontend/src/lib/stores/trails.svelte.ts | 131 - frontend/src/lib/time.ts | 120 - frontend/src/lib/trailStatus.ts | 38 - frontend/src/lib/types.d.ts | 346 - frontend/src/lib/utils.ts | 13 - frontend/src/routes/+layout.svelte | 39 +- frontend/src/routes/+layout.ts | 15 + frontend/src/routes/+page.svelte | 97 +- frontend/src/routes/dashboard/+layout.svelte | 409 -- frontend/src/routes/dashboard/+page.svelte | 128 - .../src/routes/dashboard/events/+page.svelte | 534 -- .../routes/dashboard/events/[id]/+page.svelte | 702 -- .../events/[id]/riders/[riderId]/+page.svelte | 248 - .../events/[id]/stages/[stageId]/+page.svelte | 478 -- .../routes/dashboard/settings/+layout.svelte | 51 - .../routes/dashboard/settings/+page.svelte | 354 - .../dashboard/settings/flags/+page.svelte | 217 - .../src/routes/dashboard/team/+page.svelte | 968 --- .../src/routes/dashboard/trails/+page.svelte | 255 - .../routes/dashboard/trails/[id]/+page.svelte | 716 -- frontend/src/routes/imprint/+page.svelte | 66 + .../src/routes/invite/[token]/+page.svelte | 228 - frontend/src/routes/login/+page.svelte | 94 - frontend/src/routes/privacy/+page.svelte | 72 + frontend/src/service-worker.ts | 125 - frontend/static/manifest.webmanifest | 20 - frontend/static/pedaler.svg | 95 - frontend/static/radl.svg | 942 --- frontend/static/schildl.svg | 896 --- frontend/static/stammtisch-hersbruck.svg | 1 + frontend/svelte.config.js | 32 +- 164 files changed, 814 insertions(+), 22232 deletions(-) delete mode 100644 TODO.md delete mode 100644 UML_basic.png create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/bun.lock create mode 100644 frontend/docker-compose.yaml create mode 100644 frontend/nginx.conf delete mode 100644 frontend/package-lock.json delete mode 100644 frontend/scripts/schema-to-json.mjs delete mode 100644 frontend/src/lib/assets/favicon.svg delete mode 100644 frontend/src/lib/components/Avatar.svelte delete mode 100644 frontend/src/lib/components/ClockSyncDialog.svelte delete mode 100644 frontend/src/lib/components/ConfirmDialog.svelte delete mode 100644 frontend/src/lib/components/CreateTeamDialog.svelte delete mode 100644 frontend/src/lib/components/ElevationProfile.svelte delete mode 100644 frontend/src/lib/components/FlagIcon.svelte delete mode 100644 frontend/src/lib/components/IconPicker.svelte create mode 100644 frontend/src/lib/components/LegalPage.svelte delete mode 100644 frontend/src/lib/components/Logo.svelte delete mode 100644 frontend/src/lib/components/RunningTimesToast.svelte delete mode 100644 frontend/src/lib/components/Spinner.svelte delete mode 100644 frontend/src/lib/components/TeamMenu.svelte delete mode 100644 frontend/src/lib/components/TimeDialog.svelte delete mode 100644 frontend/src/lib/components/TrailMap.svelte delete mode 100644 frontend/src/lib/components/ui/accordion/accordion-content.svelte delete mode 100644 frontend/src/lib/components/ui/accordion/accordion-item.svelte delete mode 100644 frontend/src/lib/components/ui/accordion/accordion-trigger.svelte delete mode 100644 frontend/src/lib/components/ui/accordion/accordion.svelte delete mode 100644 frontend/src/lib/components/ui/accordion/index.ts delete mode 100644 frontend/src/lib/components/ui/badge/badge.svelte delete mode 100644 frontend/src/lib/components/ui/badge/index.ts delete mode 100644 frontend/src/lib/components/ui/button/button.svelte delete mode 100644 frontend/src/lib/components/ui/button/index.ts delete mode 100644 frontend/src/lib/components/ui/card/card-action.svelte delete mode 100644 frontend/src/lib/components/ui/card/card-content.svelte delete mode 100644 frontend/src/lib/components/ui/card/card-description.svelte delete mode 100644 frontend/src/lib/components/ui/card/card-footer.svelte delete mode 100644 frontend/src/lib/components/ui/card/card-header.svelte delete mode 100644 frontend/src/lib/components/ui/card/card-title.svelte delete mode 100644 frontend/src/lib/components/ui/card/card.svelte delete mode 100644 frontend/src/lib/components/ui/card/index.ts delete mode 100644 frontend/src/lib/components/ui/dialog/dialog-close.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/dialog-content.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/dialog-description.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/dialog-footer.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/dialog-header.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/dialog-overlay.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/dialog-portal.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/dialog-title.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/dialog-trigger.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/dialog.svelte delete mode 100644 frontend/src/lib/components/ui/dialog/index.ts delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte delete mode 100644 frontend/src/lib/components/ui/dropdown-menu/index.ts delete mode 100644 frontend/src/lib/components/ui/input/index.ts delete mode 100644 frontend/src/lib/components/ui/input/input.svelte delete mode 100644 frontend/src/lib/components/ui/label/index.ts delete mode 100644 frontend/src/lib/components/ui/label/label.svelte delete mode 100644 frontend/src/lib/components/ui/navigation-menu/index.ts delete mode 100644 frontend/src/lib/components/ui/navigation-menu/navigation-menu-content.svelte delete mode 100644 frontend/src/lib/components/ui/navigation-menu/navigation-menu-indicator.svelte delete mode 100644 frontend/src/lib/components/ui/navigation-menu/navigation-menu-item.svelte delete mode 100644 frontend/src/lib/components/ui/navigation-menu/navigation-menu-link.svelte delete mode 100644 frontend/src/lib/components/ui/navigation-menu/navigation-menu-list.svelte delete mode 100644 frontend/src/lib/components/ui/navigation-menu/navigation-menu-trigger.svelte delete mode 100644 frontend/src/lib/components/ui/navigation-menu/navigation-menu-viewport.svelte delete mode 100644 frontend/src/lib/components/ui/navigation-menu/navigation-menu.svelte delete mode 100644 frontend/src/lib/components/ui/select/index.ts delete mode 100644 frontend/src/lib/components/ui/select/select-content.svelte delete mode 100644 frontend/src/lib/components/ui/select/select-group-heading.svelte delete mode 100644 frontend/src/lib/components/ui/select/select-group.svelte delete mode 100644 frontend/src/lib/components/ui/select/select-item.svelte delete mode 100644 frontend/src/lib/components/ui/select/select-label.svelte delete mode 100644 frontend/src/lib/components/ui/select/select-portal.svelte delete mode 100644 frontend/src/lib/components/ui/select/select-scroll-down-button.svelte delete mode 100644 frontend/src/lib/components/ui/select/select-scroll-up-button.svelte delete mode 100644 frontend/src/lib/components/ui/select/select-separator.svelte delete mode 100644 frontend/src/lib/components/ui/select/select-trigger.svelte delete mode 100644 frontend/src/lib/components/ui/select/select.svelte delete mode 100644 frontend/src/lib/components/ui/separator/index.ts delete mode 100644 frontend/src/lib/components/ui/separator/separator.svelte delete mode 100644 frontend/src/lib/components/ui/table/index.ts delete mode 100644 frontend/src/lib/components/ui/table/table-body.svelte delete mode 100644 frontend/src/lib/components/ui/table/table-caption.svelte delete mode 100644 frontend/src/lib/components/ui/table/table-cell.svelte delete mode 100644 frontend/src/lib/components/ui/table/table-footer.svelte delete mode 100644 frontend/src/lib/components/ui/table/table-head.svelte delete mode 100644 frontend/src/lib/components/ui/table/table-header.svelte delete mode 100644 frontend/src/lib/components/ui/table/table-row.svelte delete mode 100644 frontend/src/lib/components/ui/table/table.svelte delete mode 100644 frontend/src/lib/flagIcons.ts delete mode 100644 frontend/src/lib/gpx.test.ts delete mode 100644 frontend/src/lib/gpx.ts delete mode 100644 frontend/src/lib/index.ts delete mode 100644 frontend/src/lib/stores/app.svelte.ts delete mode 100644 frontend/src/lib/stores/clockSync.svelte.ts delete mode 100644 frontend/src/lib/stores/connection.svelte.ts delete mode 100644 frontend/src/lib/stores/eventParticipants.svelte.ts delete mode 100644 frontend/src/lib/stores/eventSeries.svelte.ts delete mode 100644 frontend/src/lib/stores/eventSeries.test.ts delete mode 100644 frontend/src/lib/stores/events.svelte.ts delete mode 100644 frontend/src/lib/stores/pocketbase.svelte.ts delete mode 100644 frontend/src/lib/stores/riders.svelte.ts delete mode 100644 frontend/src/lib/stores/stages.svelte.ts delete mode 100644 frontend/src/lib/stores/teamInvites.svelte.ts delete mode 100644 frontend/src/lib/stores/teams.svelte.ts delete mode 100644 frontend/src/lib/stores/ticker.svelte.ts delete mode 100644 frontend/src/lib/stores/timeOutbox.svelte.ts delete mode 100644 frontend/src/lib/stores/times.svelte.ts delete mode 100644 frontend/src/lib/stores/trailComments.svelte.ts delete mode 100644 frontend/src/lib/stores/trailFlags.svelte.ts delete mode 100644 frontend/src/lib/stores/trailMarkers.svelte.ts delete mode 100644 frontend/src/lib/stores/trailVersions.svelte.ts delete mode 100644 frontend/src/lib/stores/trails.svelte.ts delete mode 100644 frontend/src/lib/time.ts delete mode 100644 frontend/src/lib/trailStatus.ts delete mode 100644 frontend/src/lib/types.d.ts delete mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/routes/+layout.ts delete mode 100644 frontend/src/routes/dashboard/+layout.svelte delete mode 100644 frontend/src/routes/dashboard/+page.svelte delete mode 100644 frontend/src/routes/dashboard/events/+page.svelte delete mode 100644 frontend/src/routes/dashboard/events/[id]/+page.svelte delete mode 100644 frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte delete mode 100644 frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte delete mode 100644 frontend/src/routes/dashboard/settings/+layout.svelte delete mode 100644 frontend/src/routes/dashboard/settings/+page.svelte delete mode 100644 frontend/src/routes/dashboard/settings/flags/+page.svelte delete mode 100644 frontend/src/routes/dashboard/team/+page.svelte delete mode 100644 frontend/src/routes/dashboard/trails/+page.svelte delete mode 100644 frontend/src/routes/dashboard/trails/[id]/+page.svelte create mode 100644 frontend/src/routes/imprint/+page.svelte delete mode 100644 frontend/src/routes/invite/[token]/+page.svelte delete mode 100644 frontend/src/routes/login/+page.svelte create mode 100644 frontend/src/routes/privacy/+page.svelte delete mode 100644 frontend/src/service-worker.ts delete mode 100644 frontend/static/manifest.webmanifest delete mode 100644 frontend/static/pedaler.svg delete mode 100644 frontend/static/radl.svg delete mode 100644 frontend/static/schildl.svg create mode 100644 frontend/static/stammtisch-hersbruck.svg diff --git a/CLAUDE.md b/CLAUDE.md index abca6e6..0c25009 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,175 +1,46 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Was dieses Repo ist -## Project Overview +Ein Wegweiser, keine Anwendung. Drei vorgerenderte Seiten unter +`stammtisch-hersbruck.de`: Wappen, ein Satz, ein Knopf nach +[kurbeler.de](https://kurbeler.de), dazu Impressum und Datenschutz. -stammtisch-hersbruck.de is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, stages, riders, times and teams for a motorsport/cycling event organization. +Die Anwendung, die hier bis September 2026 lag, heißt jetzt **Kurbeler** und +wird in `~/Dokumente/Projekte/kurbeler` fortgeführt. **Neue Features gehören +dorthin, nicht hierher.** Wer hier eine Zeitnahme, einen Kader oder ein Event +einbaut, baut sie ein zweites Mal — und ab dem ersten Tag läuft sie +auseinander. -The repository is a monorepo: the SvelteKit app lives in `frontend/`, the PocketBase instance in `backend/`. +## Die Grenze -## Development Commands +Was diese Seite darf: über sich selbst Auskunft geben und weiterzeigen. -Alle Frontend-Befehle laufen aus `frontend/`: +Was sie nicht darf: irgendetwas laden, speichern oder abfragen. Kein +PocketBase, keine Anmeldung, kein Formular, keine Cookies, keine Schriften und +Karten von fremden Servern. Die Datenschutzerklärung sagt das zu, und sie ist +nur so lange wahr, wie es so bleibt. Jede Einbindung, die eine Anfrage an einen +dritten Server auslöst, macht sie zu einer falschen Auskunft über die +Rechtslage. -```bash -cd frontend +## Aufbau -# Start development server (runs on http://stammtisch-hersbruck.de.localhost:31337) -npm run dev +- `frontend/src/routes/+page.svelte` — der Wegweiser +- `frontend/src/routes/imprint`, `privacy` — Rechtstexte, über + `LegalPage.svelte` gerahmt +- `frontend/src/routes/+layout.ts` — `prerender = true` für alles +- `frontend/static/stammtisch-hersbruck.svg` — das Wappen -# Build for production -npm run build +`adapter-static`, kein Server im Betrieb. Gebaut wird mit Bun, ausgeliefert +mit nginx (`frontend/Dockerfile`). -# Preview production build -npm run preview +## Sprache -# Type-check Svelte files -npm run check +Oberflächentexte und Kommentare deutsch, Bezeichner und Routenpfade englisch +(`/imprint`, nicht `/impressum`) — dieselbe Regel wie in Kurbeler. -# Type-check with watch mode -npm run check:watch +## Das Backend -# Generate TypeScript types from the schema migration in backend/pb_migrations -# (no running instance, no token, no network access needed) -npm run generate-pocketbase-types -``` - -Backend (PocketBase) aus `backend/`: - -```bash -cd backend - -# PocketBase lokal starten (Admin-UI auf http://127.0.0.1:8090/_/) -docker compose up -d --build - -# Stoppen -docker compose down -``` - -## Architecture - -### Backend Integration (PocketBase) - -- **API Base URL**: konfigurierbar über `PUBLIC_PB_URL` in `frontend/.env`. - Default ist die produktive Instanz `https://api.stammtisch-hersbruck.de`; - für das lokale Backend aus `backend/` auf `http://127.0.0.1:8090` umstellen. -- **Schema**: versioniert als Migration in `backend/pb_migrations/`, kommt per - `COPY` ins Image (kein Bind-Mount — der würde das Image-Verzeichnis - überdecken). Im Admin-UI erzeugte Migrationen liegen deshalb zunächst nur im - Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe - `backend/README.md`. -- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts` -- **Collections**: users, teams, team_invites, events, stages, riders, times, - trails, trail_versions, trail_flags, trail_markers, trail_comments, - event_participants, event_series -- **Regeln**: Jede Zugriffsregel, die `@request.auth.id` erwähnt, beginnt mit - `@request.auth.id != "" && (…)`. Ohne diesen Vorspann ist eine *leere* - Relation gleich dem leeren `@request.auth.id` einer anonymen Anfrage — ein - Team ohne Admins stünde damit offen im Netz. Siehe - `backend/pb_migrations/1754501700_rules_require_login.js`. -- **Hooks**: `backend/pb_hooks/` enthält serverseitiges JS für das, was keine - Collection-Regel abbilden kann — derzeit die öffentlichen Routen der - Einladungslinks. Jeder Route-Handler läuft in einer eigenen JS-Laufzeit; - gemeinsamer Code gehört in ein Modul und wird per - ``require(`${__hooks}/…`)`` im Handler geholt. Details in - `backend/README.md`. -- **Authentication**: Handled through `src/lib/stores/pocketbase.svelte.ts` with the `AuthStore` class -- **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs - -### State Management Pattern - -The app uses Svelte 5 runes for state management with a custom store pattern: - -1. **Global stores** in `src/lib/stores/`: - - `pocketbase.svelte.ts`: PocketBase client, auth, and collection operations - - `app.svelte.ts`: Global app state (theme, navigation, confirm dialogs, hotkeys) - - `teams.svelte.ts`: Example of collection-specific store pattern - -2. **Collection store pattern**: - - Each collection has a context-based store (see `teams.svelte.ts` as template) - - Use `setTeamContext()` in parent and `getTeamContext()` in children - - Stores provide: `records`, `refresh()`, `create()`, `edit()`, `remove()` - - The `collections` helper in `pocketbase.svelte.ts` provides reusable CRUD operations - -3. **Auth flow**: - - Auth state lives in `auth` store from `pocketbase.svelte.ts` - - Cookie-based persistence available via `auth.cookie` flag - - Auth store syncs with PocketBase `authStore.onChange()` - -### UI Components - -- **UI Library**: Using shadcn-svelte components (bits-ui based) -- **Component path alias**: `@/components/ui/*` maps to `$lib/components/ui/*` -- **Styling**: Tailwind CSS 4.x with custom configuration -- **Dark mode**: Handled by `mode-watcher` package, toggle with `toggleMode()` -- **Tooltips**: Global `tooltip` action available from `app.svelte.ts` using tippy.js - -### Important Patterns - -**Svelte 5 Runes**: This project uses Svelte 5 syntax exclusively: -- `$state()` for reactive state (not `let` with `$:`) -- `$props()` for component props -- `$derived()` for computed values -- `{@render children?.()}` for slot content - -**Type Generation**: After modifying the schema migration in `backend/pb_migrations/`, run `npm run generate-pocketbase-types` to update `frontend/src/lib/types.d.ts`. The script reads the collections array straight out of the migration (`frontend/scripts/schema-to-json.mjs`) — schema and types come from the same versioned source and cannot drift apart. Commit the regenerated file. - -**Async Data Loading**: Root layout (`src/routes/+layout.svelte`) shows pattern: -```svelte -{#await load()} - -{:then _} - -{/await} -``` - -**Confirm Dialogs**: Use `app.confirm.request()` for user confirmations (see `events.svelte.ts` remove pattern). - -**Hotkeys**: Use `app.hotkey(event, condition, callback)` which auto-ignores input fields and contenteditable elements. - -**Tooltips**: Never use the `title` attribute for tooltips — appwide. ` - - - - diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte deleted file mode 100644 index 28139fc..0000000 --- a/frontend/src/lib/components/ConfirmDialog.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - open, onOpenChange}> - - - {app.confirm.title} - {#if app.confirm.text} - {app.confirm.text} - {/if} - - - {#if app.confirm.error} -
- {typeof app.confirm.error === 'object' ? (app.confirm.error as any).message ?? 'Fehler' : 'Fehler'} -
- {/if} - - - - - -
-
diff --git a/frontend/src/lib/components/CreateTeamDialog.svelte b/frontend/src/lib/components/CreateTeamDialog.svelte deleted file mode 100644 index d9aaddf..0000000 --- a/frontend/src/lib/components/CreateTeamDialog.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - Neues Team - Du wirst automatisch Owner. - -
-
- - e.key === 'Enter' && submit()} /> -
- {#if error} -

{error}

- {/if} -
- - - - -
-
diff --git a/frontend/src/lib/components/ElevationProfile.svelte b/frontend/src/lib/components/ElevationProfile.svelte deleted file mode 100644 index 2d2f6ef..0000000 --- a/frontend/src/lib/components/ElevationProfile.svelte +++ /dev/null @@ -1,217 +0,0 @@ - - -{#if stats} -
- - - - {#if hover} - - - - {Math.round(hover.ele)} m · {(hover.d / 1000).toFixed(2)} km - - {/if} -
-
- {Math.round(stats.min)} m - {(stats.totalD / 1000).toFixed(1)} km - {Math.round(stats.max)} m -
-{:else} -

Keine Höhendaten vorhanden.

-{/if} diff --git a/frontend/src/lib/components/FlagIcon.svelte b/frontend/src/lib/components/FlagIcon.svelte deleted file mode 100644 index ce41fa7..0000000 --- a/frontend/src/lib/components/FlagIcon.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - - - {#if loading} - {#await loading} - - {:then mod} - {@const Icon = mod.default as typeof Flag} - - {:catch} - - {/await} - {:else} - - {/if} - diff --git a/frontend/src/lib/components/IconPicker.svelte b/frontend/src/lib/components/IconPicker.svelte deleted file mode 100644 index 88a8c24..0000000 --- a/frontend/src/lib/components/IconPicker.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -
- - - -
- {#each FLAG_ICON_SUGGESTIONS as name (name)} - - {/each} -
-
diff --git a/frontend/src/lib/components/LegalPage.svelte b/frontend/src/lib/components/LegalPage.svelte new file mode 100644 index 0000000..ebcdc36 --- /dev/null +++ b/frontend/src/lib/components/LegalPage.svelte @@ -0,0 +1,41 @@ + + + + {title} · Stammtisch Hersbruck + + + +
+ + ← Zurück + + +

{title}

+

Stand: {updated}

+ + +
+ {@render children()} +
+
diff --git a/frontend/src/lib/components/Logo.svelte b/frontend/src/lib/components/Logo.svelte deleted file mode 100644 index a72bb9a..0000000 --- a/frontend/src/lib/components/Logo.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/frontend/src/lib/components/RunningTimesToast.svelte b/frontend/src/lib/components/RunningTimesToast.svelte deleted file mode 100644 index d41ac4f..0000000 --- a/frontend/src/lib/components/RunningTimesToast.svelte +++ /dev/null @@ -1,243 +0,0 @@ - - -{#if running.length > 0 || timeOutbox.pending > 0 || !connection.online} - -
-
-
- {#if running.length > 0} - - - - - {/if} - - - {#if running.length === 0} - Zeitnahme - {:else if running.length === 1} - Zeit läuft - {:else} - {running.length} Zeiten laufen - {/if} - - - {#if !open} - - {running[0].formattedTime} - - {/if} - - {#if !single} - - {/if} -
- - - {#if !connection.online} -
- -
-

- Keine Verbindung - {#if connection.offlineFor !== null} - - ({since(connection.offlineFor)}) - - {/if} -

-

- Die Zeitnahme läuft weiter. Gestoppt wird auf diesem Gerät, - übertragen, sobald der Server wieder antwortet. -

-
- -
- {/if} - - - {#if timeOutbox.pending > 0} -
- - - {timeOutbox.pending} - {timeOutbox.pending === 1 ? 'Zeit liegt' : 'Zeiten liegen'} nur auf diesem - Gerät. - {#if timeOutbox.error} - {timeOutbox.error} - {/if} - - -
- {/if} - - {#if open} -
    - {#each running as t (t.id)} -
  • - - -
  • - {/each} -
- {/if} -
-
- - -{/if} diff --git a/frontend/src/lib/components/Spinner.svelte b/frontend/src/lib/components/Spinner.svelte deleted file mode 100644 index 1f30cfe..0000000 --- a/frontend/src/lib/components/Spinner.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/frontend/src/lib/components/TeamMenu.svelte b/frontend/src/lib/components/TeamMenu.svelte deleted file mode 100644 index 0aa6b4f..0000000 --- a/frontend/src/lib/components/TeamMenu.svelte +++ /dev/null @@ -1,101 +0,0 @@ - - - - - {#snippet child({ props })} - onnavigate?.()}> - - Team - - - {/snippet} - - - - - {#each teams.records as t (t.id)} - - {:else} -

Noch in keinem Team

- {/each} - -
- - -
-
- - - diff --git a/frontend/src/lib/components/TimeDialog.svelte b/frontend/src/lib/components/TimeDialog.svelte deleted file mode 100644 index c3bcf2b..0000000 --- a/frontend/src/lib/components/TimeDialog.svelte +++ /dev/null @@ -1,281 +0,0 @@ - - - - - - Zeit erfassen - - Fahrer und Stage wählen — je nach Stand wird gestartet oder gestoppt. - - - -
-
- - - {#if eventRiders.length === 0} -

Für dieses Event gibt es noch keine Fahrer.

- {:else} - -
- {#each eventRiders as r (r.id)} - {@const number = participants.numberOf(eventId, r.id)} - {@const chosen = selectedRider === r.id} - - {/each} -
- {/if} -
- -
- - - {stageLabel} - - {#each eventStages as r (r.id)} - {r.description || 'Stage'} - {/each} - - - {#if eventStages.length === 0} -

Für dieses Event gibt es noch keine Stages.

- {/if} -
- - {#if active} -
-

läuft seit

-

{active.formattedTime}

-
- {:else if existing} -

- Für diese Paarung gibt es bereits eine Zeit ({existing.formattedTime}). - Ein neuer Start legt eine zweite an. -

- {/if} - - {#if error} -

{error}

- {/if} -
- - {#if confirmDnf} - -
-

- - - Wirklich als DNF werten? Die Zeit wird beendet und taucht in keiner - Rangliste auf. - -

-
- - -
-
- {:else} - - {#if active} -
- - -
- {:else} - - {/if} - - - - - {/if} -
-
diff --git a/frontend/src/lib/components/TrailMap.svelte b/frontend/src/lib/components/TrailMap.svelte deleted file mode 100644 index 6329beb..0000000 --- a/frontend/src/lib/components/TrailMap.svelte +++ /dev/null @@ -1,494 +0,0 @@ - - -
- - - diff --git a/frontend/src/lib/components/ui/accordion/accordion-content.svelte b/frontend/src/lib/components/ui/accordion/accordion-content.svelte deleted file mode 100644 index cf7045e..0000000 --- a/frontend/src/lib/components/ui/accordion/accordion-content.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - -
- {@render children?.()} -
-
diff --git a/frontend/src/lib/components/ui/accordion/accordion-item.svelte b/frontend/src/lib/components/ui/accordion/accordion-item.svelte deleted file mode 100644 index 780545c..0000000 --- a/frontend/src/lib/components/ui/accordion/accordion-item.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/accordion/accordion-trigger.svelte b/frontend/src/lib/components/ui/accordion/accordion-trigger.svelte deleted file mode 100644 index 0027cdd..0000000 --- a/frontend/src/lib/components/ui/accordion/accordion-trigger.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - svg]:rotate-180", - className - )} - {...restProps} - > - {@render children?.()} - - - diff --git a/frontend/src/lib/components/ui/accordion/accordion.svelte b/frontend/src/lib/components/ui/accordion/accordion.svelte deleted file mode 100644 index 117ee37..0000000 --- a/frontend/src/lib/components/ui/accordion/accordion.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/accordion/index.ts b/frontend/src/lib/components/ui/accordion/index.ts deleted file mode 100644 index ac343a1..0000000 --- a/frontend/src/lib/components/ui/accordion/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import Root from "./accordion.svelte"; -import Content from "./accordion-content.svelte"; -import Item from "./accordion-item.svelte"; -import Trigger from "./accordion-trigger.svelte"; - -export { - Root, - Content, - Item, - Trigger, - // - Root as Accordion, - Content as AccordionContent, - Item as AccordionItem, - Trigger as AccordionTrigger, -}; diff --git a/frontend/src/lib/components/ui/badge/badge.svelte b/frontend/src/lib/components/ui/badge/badge.svelte deleted file mode 100644 index 51bbc23..0000000 --- a/frontend/src/lib/components/ui/badge/badge.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/badge/index.ts b/frontend/src/lib/components/ui/badge/index.ts deleted file mode 100644 index 64e0aa9..0000000 --- a/frontend/src/lib/components/ui/badge/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Badge } from "./badge.svelte"; -export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte deleted file mode 100644 index 46e930b..0000000 --- a/frontend/src/lib/components/ui/button/button.svelte +++ /dev/null @@ -1,95 +0,0 @@ - - - - -{#if href} - - {@render children?.()} - -{:else} - -{/if} diff --git a/frontend/src/lib/components/ui/button/index.ts b/frontend/src/lib/components/ui/button/index.ts deleted file mode 100644 index fb585d7..0000000 --- a/frontend/src/lib/components/ui/button/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import Root, { - type ButtonProps, - type ButtonSize, - type ButtonVariant, - buttonVariants, -} from "./button.svelte"; - -export { - Root, - type ButtonProps as Props, - // - Root as Button, - buttonVariants, - type ButtonProps, - type ButtonSize, - type ButtonVariant, -}; diff --git a/frontend/src/lib/components/ui/card/card-action.svelte b/frontend/src/lib/components/ui/card/card-action.svelte deleted file mode 100644 index 7c48844..0000000 --- a/frontend/src/lib/components/ui/card/card-action.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte deleted file mode 100644 index 4f60ee3..0000000 --- a/frontend/src/lib/components/ui/card/card-content.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte deleted file mode 100644 index 9b20ac7..0000000 --- a/frontend/src/lib/components/ui/card/card-description.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -

- {@render children?.()} -

diff --git a/frontend/src/lib/components/ui/card/card-footer.svelte b/frontend/src/lib/components/ui/card/card-footer.svelte deleted file mode 100644 index 1efff6d..0000000 --- a/frontend/src/lib/components/ui/card/card-footer.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte deleted file mode 100644 index 7eb69e1..0000000 --- a/frontend/src/lib/components/ui/card/card-header.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte deleted file mode 100644 index 1523fc5..0000000 --- a/frontend/src/lib/components/ui/card/card-title.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte deleted file mode 100644 index 2b4f81a..0000000 --- a/frontend/src/lib/components/ui/card/card.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - -
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)} - {...restProps} -> - {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/card/index.ts b/frontend/src/lib/components/ui/card/index.ts deleted file mode 100644 index 4d3fce4..0000000 --- a/frontend/src/lib/components/ui/card/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import Root from "./card.svelte"; -import Content from "./card-content.svelte"; -import Description from "./card-description.svelte"; -import Footer from "./card-footer.svelte"; -import Header from "./card-header.svelte"; -import Title from "./card-title.svelte"; -import Action from "./card-action.svelte"; - -export { - Root, - Content, - Description, - Footer, - Header, - Title, - Action, - // - Root as Card, - Content as CardContent, - Description as CardDescription, - Footer as CardFooter, - Header as CardHeader, - Title as CardTitle, - Action as CardAction, -}; diff --git a/frontend/src/lib/components/ui/dialog/dialog-close.svelte b/frontend/src/lib/components/ui/dialog/dialog-close.svelte deleted file mode 100644 index de68f2f..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog-close.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte deleted file mode 100644 index c0551ee..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog-content.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - {@render children?.()} - {#if showCloseButton} - - {#snippet child({ props })} - - {/snippet} - - {/if} - - diff --git a/frontend/src/lib/components/ui/dialog/dialog-description.svelte b/frontend/src/lib/components/ui/dialog/dialog-description.svelte deleted file mode 100644 index 0102d91..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog-description.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dialog/dialog-footer.svelte b/frontend/src/lib/components/ui/dialog/dialog-footer.svelte deleted file mode 100644 index e23160a..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog-footer.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -
- {@render children?.()} - {#if showCloseButton} - - {#snippet child({ props })} - - {/snippet} - - {/if} -
diff --git a/frontend/src/lib/components/ui/dialog/dialog-header.svelte b/frontend/src/lib/components/ui/dialog/dialog-header.svelte deleted file mode 100644 index c3ce8a2..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog-header.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte b/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte deleted file mode 100644 index 19f69f0..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dialog/dialog-portal.svelte b/frontend/src/lib/components/ui/dialog/dialog-portal.svelte deleted file mode 100644 index ccfa79c..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog-portal.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dialog/dialog-title.svelte b/frontend/src/lib/components/ui/dialog/dialog-title.svelte deleted file mode 100644 index 6ff1a4a..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog-title.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte b/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte deleted file mode 100644 index 589ee0c..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dialog/dialog.svelte b/frontend/src/lib/components/ui/dialog/dialog.svelte deleted file mode 100644 index 211672c..0000000 --- a/frontend/src/lib/components/ui/dialog/dialog.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts deleted file mode 100644 index 076cef5..0000000 --- a/frontend/src/lib/components/ui/dialog/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Root from "./dialog.svelte"; -import Portal from "./dialog-portal.svelte"; -import Title from "./dialog-title.svelte"; -import Footer from "./dialog-footer.svelte"; -import Header from "./dialog-header.svelte"; -import Overlay from "./dialog-overlay.svelte"; -import Content from "./dialog-content.svelte"; -import Description from "./dialog-description.svelte"; -import Trigger from "./dialog-trigger.svelte"; -import Close from "./dialog-close.svelte"; - -export { - Root, - Title, - Portal, - Footer, - Header, - Trigger, - Overlay, - Content, - Description, - Close, - // - Root as Dialog, - Title as DialogTitle, - Portal as DialogPortal, - Footer as DialogFooter, - Header as DialogHeader, - Trigger as DialogTrigger, - Overlay as DialogOverlay, - Content as DialogContent, - Description as DialogDescription, - Close as DialogClose, -}; diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte deleted file mode 100644 index e0e1971..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte deleted file mode 100644 index a81b48d..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - {#snippet children({ checked, indeterminate })} - - {#if indeterminate} - - {:else if checked} - - {/if} - - {@render childrenProp?.()} - {/snippet} - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte deleted file mode 100644 index 261a5b0..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte deleted file mode 100644 index 433540f..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte deleted file mode 100644 index aca1f7b..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte deleted file mode 100644 index c425190..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte deleted file mode 100644 index e0c534f..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +++ /dev/null @@ -1,24 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte deleted file mode 100644 index 274cfef..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte deleted file mode 100644 index 189aef4..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte deleted file mode 100644 index c8aa07b..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - - {#snippet children({ checked })} - - {#if checked} - - {/if} - - {@render childrenProp?.({ checked })} - {/snippet} - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte deleted file mode 100644 index 90f1b6f..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte deleted file mode 100644 index ed7cc85..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte deleted file mode 100644 index b5750d4..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte deleted file mode 100644 index fab0275..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - {@render children?.()} - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte deleted file mode 100644 index f044581..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte deleted file mode 100644 index cb05344..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte deleted file mode 100644 index cb4bc62..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/dropdown-menu/index.ts b/frontend/src/lib/components/ui/dropdown-menu/index.ts deleted file mode 100644 index 7850c6a..0000000 --- a/frontend/src/lib/components/ui/dropdown-menu/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import Root from "./dropdown-menu.svelte"; -import Sub from "./dropdown-menu-sub.svelte"; -import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte"; -import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; -import Content from "./dropdown-menu-content.svelte"; -import Group from "./dropdown-menu-group.svelte"; -import Item from "./dropdown-menu-item.svelte"; -import Label from "./dropdown-menu-label.svelte"; -import RadioGroup from "./dropdown-menu-radio-group.svelte"; -import RadioItem from "./dropdown-menu-radio-item.svelte"; -import Separator from "./dropdown-menu-separator.svelte"; -import Shortcut from "./dropdown-menu-shortcut.svelte"; -import Trigger from "./dropdown-menu-trigger.svelte"; -import SubContent from "./dropdown-menu-sub-content.svelte"; -import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; -import GroupHeading from "./dropdown-menu-group-heading.svelte"; -import Portal from "./dropdown-menu-portal.svelte"; - -export { - CheckboxGroup, - CheckboxItem, - Content, - Portal, - Root as DropdownMenu, - CheckboxGroup as DropdownMenuCheckboxGroup, - CheckboxItem as DropdownMenuCheckboxItem, - Content as DropdownMenuContent, - Portal as DropdownMenuPortal, - Group as DropdownMenuGroup, - Item as DropdownMenuItem, - Label as DropdownMenuLabel, - RadioGroup as DropdownMenuRadioGroup, - RadioItem as DropdownMenuRadioItem, - Separator as DropdownMenuSeparator, - Shortcut as DropdownMenuShortcut, - Sub as DropdownMenuSub, - SubContent as DropdownMenuSubContent, - SubTrigger as DropdownMenuSubTrigger, - Trigger as DropdownMenuTrigger, - GroupHeading as DropdownMenuGroupHeading, - Group, - GroupHeading, - Item, - Label, - RadioGroup, - RadioItem, - Root, - Separator, - Shortcut, - Sub, - SubContent, - SubTrigger, - Trigger, -}; diff --git a/frontend/src/lib/components/ui/input/index.ts b/frontend/src/lib/components/ui/input/index.ts deleted file mode 100644 index f47b6d3..0000000 --- a/frontend/src/lib/components/ui/input/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Root from "./input.svelte"; - -export { - Root, - // - Root as Input, -}; diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte deleted file mode 100644 index fe7db38..0000000 --- a/frontend/src/lib/components/ui/input/input.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - -{#if type === "file"} - -{:else} - -{/if} diff --git a/frontend/src/lib/components/ui/label/index.ts b/frontend/src/lib/components/ui/label/index.ts deleted file mode 100644 index 8bfca0b..0000000 --- a/frontend/src/lib/components/ui/label/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Root from "./label.svelte"; - -export { - Root, - // - Root as Label, -}; diff --git a/frontend/src/lib/components/ui/label/label.svelte b/frontend/src/lib/components/ui/label/label.svelte deleted file mode 100644 index d5e3086..0000000 --- a/frontend/src/lib/components/ui/label/label.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/navigation-menu/index.ts b/frontend/src/lib/components/ui/navigation-menu/index.ts deleted file mode 100644 index 208cb93..0000000 --- a/frontend/src/lib/components/ui/navigation-menu/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import Content from "./navigation-menu-content.svelte"; -import Indicator from "./navigation-menu-indicator.svelte"; -import Item from "./navigation-menu-item.svelte"; -import Link from "./navigation-menu-link.svelte"; -import List from "./navigation-menu-list.svelte"; -import Trigger from "./navigation-menu-trigger.svelte"; -import Viewport from "./navigation-menu-viewport.svelte"; -import Root from "./navigation-menu.svelte"; - -export { - Root, - Content, - Indicator, - Item, - Link, - List, - Trigger, - Viewport, - // - Root as NavigationMenuRoot, - Content as NavigationMenuContent, - Indicator as NavigationMenuIndicator, - Item as NavigationMenuItem, - Link as NavigationMenuLink, - List as NavigationMenuList, - Trigger as NavigationMenuTrigger, - Viewport as NavigationMenuViewport, -}; diff --git a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-content.svelte b/frontend/src/lib/components/ui/navigation-menu/navigation-menu-content.svelte deleted file mode 100644 index 0b6b6c5..0000000 --- a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-content.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-indicator.svelte b/frontend/src/lib/components/ui/navigation-menu/navigation-menu-indicator.svelte deleted file mode 100644 index f4cf060..0000000 --- a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-indicator.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - -
-
diff --git a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-item.svelte b/frontend/src/lib/components/ui/navigation-menu/navigation-menu-item.svelte deleted file mode 100644 index 4760458..0000000 --- a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-item.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-link.svelte b/frontend/src/lib/components/ui/navigation-menu/navigation-menu-link.svelte deleted file mode 100644 index 742d9bd..0000000 --- a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-link.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-list.svelte b/frontend/src/lib/components/ui/navigation-menu/navigation-menu-list.svelte deleted file mode 100644 index 6f36137..0000000 --- a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-list.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-trigger.svelte b/frontend/src/lib/components/ui/navigation-menu/navigation-menu-trigger.svelte deleted file mode 100644 index 64e269a..0000000 --- a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-trigger.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-viewport.svelte b/frontend/src/lib/components/ui/navigation-menu/navigation-menu-viewport.svelte deleted file mode 100644 index b2ce064..0000000 --- a/frontend/src/lib/components/ui/navigation-menu/navigation-menu-viewport.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - -
- -
diff --git a/frontend/src/lib/components/ui/navigation-menu/navigation-menu.svelte b/frontend/src/lib/components/ui/navigation-menu/navigation-menu.svelte deleted file mode 100644 index 27cd0d6..0000000 --- a/frontend/src/lib/components/ui/navigation-menu/navigation-menu.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - - {@render children?.()} - {#if viewport} - - {/if} - diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts deleted file mode 100644 index 4dec358..0000000 --- a/frontend/src/lib/components/ui/select/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import Root from "./select.svelte"; -import Group from "./select-group.svelte"; -import Label from "./select-label.svelte"; -import Item from "./select-item.svelte"; -import Content from "./select-content.svelte"; -import Trigger from "./select-trigger.svelte"; -import Separator from "./select-separator.svelte"; -import ScrollDownButton from "./select-scroll-down-button.svelte"; -import ScrollUpButton from "./select-scroll-up-button.svelte"; -import GroupHeading from "./select-group-heading.svelte"; -import Portal from "./select-portal.svelte"; - -export { - Root, - Group, - Label, - Item, - Content, - Trigger, - Separator, - ScrollDownButton, - ScrollUpButton, - GroupHeading, - Portal, - // - Root as Select, - Group as SelectGroup, - Label as SelectLabel, - Item as SelectItem, - Content as SelectContent, - Trigger as SelectTrigger, - Separator as SelectSeparator, - ScrollDownButton as SelectScrollDownButton, - ScrollUpButton as SelectScrollUpButton, - GroupHeading as SelectGroupHeading, - Portal as SelectPortal, -}; diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte deleted file mode 100644 index bb1e2ac..0000000 --- a/frontend/src/lib/components/ui/select/select-content.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - {@render children?.()} - - - - diff --git a/frontend/src/lib/components/ui/select/select-group-heading.svelte b/frontend/src/lib/components/ui/select/select-group-heading.svelte deleted file mode 100644 index 1fab5f0..0000000 --- a/frontend/src/lib/components/ui/select/select-group-heading.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/select/select-group.svelte b/frontend/src/lib/components/ui/select/select-group.svelte deleted file mode 100644 index f666cb2..0000000 --- a/frontend/src/lib/components/ui/select/select-group.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte deleted file mode 100644 index 32fd5ce..0000000 --- a/frontend/src/lib/components/ui/select/select-item.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - {#snippet children({ selected, highlighted })} - - {#if selected} - - {/if} - - {#if childrenProp} - {@render childrenProp({ selected, highlighted })} - {:else} - {label || value} - {/if} - {/snippet} - diff --git a/frontend/src/lib/components/ui/select/select-label.svelte b/frontend/src/lib/components/ui/select/select-label.svelte deleted file mode 100644 index 69bcfdf..0000000 --- a/frontend/src/lib/components/ui/select/select-label.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/select/select-portal.svelte b/frontend/src/lib/components/ui/select/select-portal.svelte deleted file mode 100644 index 424bcdd..0000000 --- a/frontend/src/lib/components/ui/select/select-portal.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte deleted file mode 100644 index 94f41cd..0000000 --- a/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - - diff --git a/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte deleted file mode 100644 index 035ea09..0000000 --- a/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - - diff --git a/frontend/src/lib/components/ui/select/select-separator.svelte b/frontend/src/lib/components/ui/select/select-separator.svelte deleted file mode 100644 index 3b24bab..0000000 --- a/frontend/src/lib/components/ui/select/select-separator.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte deleted file mode 100644 index 03d06d0..0000000 --- a/frontend/src/lib/components/ui/select/select-trigger.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - {@render children?.()} - - diff --git a/frontend/src/lib/components/ui/select/select.svelte b/frontend/src/lib/components/ui/select/select.svelte deleted file mode 100644 index 05eb663..0000000 --- a/frontend/src/lib/components/ui/select/select.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/separator/index.ts b/frontend/src/lib/components/ui/separator/index.ts deleted file mode 100644 index 82442d2..0000000 --- a/frontend/src/lib/components/ui/separator/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Root from "./separator.svelte"; - -export { - Root, - // - Root as Separator, -}; diff --git a/frontend/src/lib/components/ui/separator/separator.svelte b/frontend/src/lib/components/ui/separator/separator.svelte deleted file mode 100644 index 5fd8a42..0000000 --- a/frontend/src/lib/components/ui/separator/separator.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/table/index.ts b/frontend/src/lib/components/ui/table/index.ts deleted file mode 100644 index 14695c8..0000000 --- a/frontend/src/lib/components/ui/table/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import Root from "./table.svelte"; -import Body from "./table-body.svelte"; -import Caption from "./table-caption.svelte"; -import Cell from "./table-cell.svelte"; -import Footer from "./table-footer.svelte"; -import Head from "./table-head.svelte"; -import Header from "./table-header.svelte"; -import Row from "./table-row.svelte"; - -export { - Root, - Body, - Caption, - Cell, - Footer, - Head, - Header, - Row, - // - Root as Table, - Body as TableBody, - Caption as TableCaption, - Cell as TableCell, - Footer as TableFooter, - Head as TableHead, - Header as TableHeader, - Row as TableRow, -}; diff --git a/frontend/src/lib/components/ui/table/table-body.svelte b/frontend/src/lib/components/ui/table/table-body.svelte deleted file mode 100644 index 935feae..0000000 --- a/frontend/src/lib/components/ui/table/table-body.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/table/table-caption.svelte b/frontend/src/lib/components/ui/table/table-caption.svelte deleted file mode 100644 index 4696cff..0000000 --- a/frontend/src/lib/components/ui/table/table-caption.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/table/table-cell.svelte b/frontend/src/lib/components/ui/table/table-cell.svelte deleted file mode 100644 index a998bf6..0000000 --- a/frontend/src/lib/components/ui/table/table-cell.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/table/table-footer.svelte b/frontend/src/lib/components/ui/table/table-footer.svelte deleted file mode 100644 index b9b14eb..0000000 --- a/frontend/src/lib/components/ui/table/table-footer.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -tr]:last:border-b-0", className)} - {...restProps} -> - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/table/table-head.svelte b/frontend/src/lib/components/ui/table/table-head.svelte deleted file mode 100644 index 267c4e0..0000000 --- a/frontend/src/lib/components/ui/table/table-head.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/table/table-header.svelte b/frontend/src/lib/components/ui/table/table-header.svelte deleted file mode 100644 index 72c02fc..0000000 --- a/frontend/src/lib/components/ui/table/table-header.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/table/table-row.svelte b/frontend/src/lib/components/ui/table/table-row.svelte deleted file mode 100644 index 90b4e2a..0000000 --- a/frontend/src/lib/components/ui/table/table-row.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - - {@render children?.()} - diff --git a/frontend/src/lib/components/ui/table/table.svelte b/frontend/src/lib/components/ui/table/table.svelte deleted file mode 100644 index d95a02e..0000000 --- a/frontend/src/lib/components/ui/table/table.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - -
- - {@render children?.()} -
-
diff --git a/frontend/src/lib/flagIcons.ts b/frontend/src/lib/flagIcons.ts deleted file mode 100644 index c1bcc34..0000000 --- a/frontend/src/lib/flagIcons.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Icons für Flag-Typen. - * - * Erlaubt ist jeder Name aus lucide.dev — die feste Liste unten sind nur - * Vorschläge fürs schnelle Klicken. Möglich macht das ein Glob über die - * Icon-Dateien des Pakets: Jedes Icon wird ein eigener Chunk und erst geladen, - * wenn es wirklich gebraucht wird. Ein statischer Import des ganzen Pakets - * hätte mehrere hundert Kilobyte für eine Handvoll Symbole gekostet, ein - * dynamischer Import mit Variable ließe sich vom Bundler nicht auflösen. - */ - -type IconModule = { default: unknown } - -const modules = import.meta.glob('/node_modules/lucide-svelte/dist/icons/*.svelte') as Record< - string, - () => Promise -> - -/** `TreePine`, `tree-pine` und `treePine` führen zum selben Icon. */ -export function toKebab(name: string): string { - return name - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/[\s_]+/g, '-') - .toLowerCase() -} - -/** Lader für einen Icon-Namen; null, wenn es das Icon nicht gibt. */ -export function iconLoader(name: string | undefined): (() => Promise) | null { - if (!name?.trim()) return null - return modules[`/node_modules/lucide-svelte/dist/icons/${toKebab(name)}.svelte`] ?? null -} - -/** - * Die Auswahl, die im Dialog zur Wahl steht — nach Themen sortiert. - * - * Abschliessend: Es gab einmal ein Freitextfeld daneben, in das sich jeder - * Name von lucide.dev eintippen liess. Das verlangte, einen Katalog von - * tausend Icons zu kennen, und belohnte den Tippfehler mit einem leeren - * Kaestchen. Fehlt hier etwas, gehoert es in diese Liste. - */ -export const FLAG_ICON_SUGGESTIONS = [ - 'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info', - 'TriangleAlert', 'Mountain', 'Droplets', 'CloudRain', 'Snowflake', 'Flame', - 'Zap', 'Bike', 'Wrench', 'Hammer', 'Shovel', 'Route', - 'Milestone', 'Signpost', 'Footprints', 'Fence', 'Lock', 'CircleParking', - 'CameraOff', 'Skull', 'Bug', 'Leaf', 'Trash2', 'Flag', -] diff --git a/frontend/src/lib/gpx.test.ts b/frontend/src/lib/gpx.test.ts deleted file mode 100644 index 2417544..0000000 --- a/frontend/src/lib/gpx.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { parseGpx, haversine, simplify, nearestOnLine, distanceAlong, positionAtDistance } from './gpx' - -// Zwei Punkte in Hersbruck, rund 1,4 km auseinander. -const SAMPLE = ` - - Testtrail - 340 - 350 - 345 - 400 - -` - -describe('haversine', () => { - it('misst die Distanz zwischen zwei Punkten in Metern', () => { - const d = haversine( - { lat: 49.51, lng: 11.43, ele: null }, - { lat: 49.52, lng: 11.43, ele: null }, - ) - // 0,01 Grad Breite sind rund 1112 m - expect(d).toBeGreaterThan(1100) - expect(d).toBeLessThan(1125) - }) - - it('liefert 0 für identische Punkte', () => { - const p = { lat: 49.51, lng: 11.43, ele: null } - expect(haversine(p, p)).toBe(0) - }) -}) - -describe('parseGpx', () => { - it('liest alle Trackpunkte', () => { - const r = parseGpx(SAMPLE) - expect(r.points).toHaveLength(4) - expect(r.points[0]).toEqual({ lat: 49.51, lng: 11.43, ele: 340 }) - }) - - it('berechnet eine plausible Streckenlänge', () => { - const r = parseGpx(SAMPLE) - expect(r.distance_m).toBeGreaterThan(400) - expect(r.distance_m).toBeLessThan(700) - }) - - it('zählt nur Anstiege über der Rauschschwelle', () => { - const r = parseGpx(SAMPLE) - // +10 (340→350), -5 (350→345, zählt nicht), +55 (345→400) = 65 - expect(r.ascent_m).toBe(65) - }) - - it('erfasst einen gleichmäßigen Anstieg auch in feinen Schritten', () => { - // 300 -> 303 in 1-m-Schritten ist kein Rauschen, sondern ein - // echter Anstieg von 3 m — nur fein aufgezeichnet. - const fine = ` - - 300 - 301 - 302 - 303 -` - expect(parseGpx(fine).ascent_m).toBe(3) - }) - - it('ignoriert Rauschen, das um denselben Wert schwankt', () => { - // Auf und ab um maximal 2 m ohne echten Höhengewinn — genau das, - // wogegen die Schwelle schützt. - const noisy = ` - - 300 - 302 - 300 - 301 - 299 - 300 -` - expect(parseGpx(noisy).ascent_m).toBe(0) - }) - - it('liefert die Bounding-Box als [[minLng,minLat],[maxLng,maxLat]]', () => { - const r = parseGpx(SAMPLE) - expect(r.bounds).toEqual([[11.43, 49.51], [11.436, 49.513]]) - }) - - it('liefert GeoJSON mit [lng, lat] in dieser Reihenfolge', () => { - const r = parseGpx(SAMPLE) - expect(r.geojson.type).toBe('LineString') - expect(r.geojson.coordinates[0]).toEqual([11.43, 49.51]) - }) - - it('liefert ein Höhenprofil mit aufsummierter Distanz', () => { - const r = parseGpx(SAMPLE) - expect(r.elevation).toHaveLength(4) - expect(r.elevation[0]).toEqual({ d: 0, ele: 340 }) - expect(r.elevation[3].d).toBeCloseTo(r.distance_m, 0) - }) - - it('kommt mit fehlenden Höhenangaben zurecht', () => { - const noEle = ` - - - -` - const r = parseGpx(noEle) - expect(r.points[0].ele).toBeNull() - expect(r.ascent_m).toBe(0) - expect(r.elevation).toHaveLength(0) - }) - - it('greift auf Routenpunkte zurück, wenn Trackpunkte fehlen', () => { - const route = ` - - 300 - 310 -` - expect(parseGpx(route).points).toHaveLength(2) - }) - - it('überspringt Punkte ohne lat/lon statt sie auf 0,0 zu setzen', () => { - const broken = ` - - 300 - 310 - 320 -` - const r = parseGpx(broken) - expect(r.points).toHaveLength(2) - // Ohne die Prüfung reichte die Bounding-Box bis 0/0 - expect(r.bounds[0][0]).toBeGreaterThan(11) - expect(r.distance_m).toBeLessThan(1000) - }) - - it('wirft bei einer Datei ohne Punkte', () => { - const empty = ` -` - expect(() => parseGpx(empty)).toThrow(/keine Punkte/i) - }) - - it('wirft bei ungültigem XML', () => { - expect(() => parseGpx('kein xml')).toThrow(/kein g(ü|ue)ltiges XML/i) - }) -}) - -describe('simplify', () => { - it('lässt kurze Punktfolgen unangetastet', () => { - const pts = [ - { lat: 49.51, lng: 11.43, ele: null }, - { lat: 49.52, lng: 11.44, ele: null }, - ] - expect(simplify(pts, 100)).toHaveLength(2) - }) - - it('reduziert lange Punktfolgen auf das Maximum', () => { - const pts = Array.from({ length: 5000 }, (_, i) => ({ - lat: 49.5 + i * 0.0001, - lng: 11.4 + Math.sin(i / 50) * 0.01, - ele: null, - })) - const out = simplify(pts, 500) - expect(out.length).toBeLessThanOrEqual(500) - expect(out.length).toBeGreaterThan(10) - }) - - it('behält Anfang und Ende bei', () => { - const pts = Array.from({ length: 1000 }, (_, i) => ({ - lat: 49.5 + i * 0.001, - lng: 11.4 + i * 0.001, - ele: null, - })) - const out = simplify(pts, 50) - expect(out[0]).toEqual(pts[0]) - expect(out[out.length - 1]).toEqual(pts[pts.length - 1]) - }) -}) - -describe('coordDistances', () => { - it('hat dieselbe Länge wie die Koordinaten', () => { - let pts = '' - for (let i = 0; i < 5000; i++) { - pts += `${300 + i * 0.01}` - } - const r = parseGpx(`${pts}`) - - expect(r.coordDistances).toHaveLength(r.geojson.coordinates.length) - // monoton steigend, beginnt bei 0, endet bei der Gesamtlänge - expect(r.coordDistances[0]).toBe(0) - expect(r.coordDistances[r.coordDistances.length - 1]).toBeCloseTo(r.distance_m, -1) - for (let i = 1; i < r.coordDistances.length; i++) { - expect(r.coordDistances[i]).toBeGreaterThanOrEqual(r.coordDistances[i - 1]) - } - }) -}) - -describe('nearestOnLine', () => { - // Zwei Segmente entlang eines Breitengrads, dann nach Norden. - const LINE: [number, number][] = [ - [11.43, 49.51], - [11.44, 49.51], - [11.44, 49.52], - ] - - it('projiziert einen Punkt neben der Linie auf das nächste Segment', () => { - const pos = nearestOnLine(LINE, 11.435, 49.5105)! - - expect(pos.index).toBe(0) - expect(pos.lat).toBeCloseTo(49.51, 6) - expect(pos.lng).toBeCloseTo(11.435, 6) - expect(pos.t).toBeCloseTo(0.5, 3) - }) - - it('bleibt an den Enden auf der Linie', () => { - const before = nearestOnLine(LINE, 11.42, 49.51)! - expect(before.lng).toBeCloseTo(11.43, 6) - expect(before.t).toBe(0) - - const after = nearestOnLine(LINE, 11.44, 49.53)! - expect(after.index).toBe(1) - expect(after.lat).toBeCloseTo(49.52, 6) - expect(after.t).toBe(1) - }) - - it('wählt das wirklich nächste Segment, nicht das erste passende', () => { - const pos = nearestOnLine(LINE, 11.4405, 49.5195)! - expect(pos.index).toBe(1) - }) - - it('liefert null ohne Koordinaten', () => { - expect(nearestOnLine([], 11.43, 49.51)).toBeNull() - }) -}) - -describe('distanceAlong', () => { - const DISTANCES = [0, 1000, 1500] - - it('interpoliert innerhalb eines Segments', () => { - expect(distanceAlong({ lng: 0, lat: 0, index: 0, t: 0.25 }, DISTANCES)).toBe(250) - expect(distanceAlong({ lng: 0, lat: 0, index: 1, t: 0.5 }, DISTANCES)).toBe(1250) - }) - - it('liefert den letzten Wert am Ende der Linie', () => { - expect(distanceAlong({ lng: 0, lat: 0, index: 2, t: 0 }, DISTANCES)).toBe(1500) - }) - - it('liefert null ohne Distanztabelle', () => { - expect(distanceAlong({ lng: 0, lat: 0, index: 0, t: 0.5 }, [])).toBeNull() - }) -}) - -describe('positionAtDistance', () => { - const LINE: [number, number][] = [ - [11.43, 49.51], - [11.44, 49.51], - [11.44, 49.52], - ] - const DISTANCES = [0, 1000, 2000] - - it('findet die Koordinate zu einem Streckenkilometer', () => { - const at = positionAtDistance(LINE, DISTANCES, 500)! - expect(at[0]).toBeCloseTo(11.435, 6) - expect(at[1]).toBeCloseTo(49.51, 6) - }) - - it('interpoliert auch im zweiten Segment', () => { - const at = positionAtDistance(LINE, DISTANCES, 1750)! - expect(at[0]).toBeCloseTo(11.44, 6) - expect(at[1]).toBeCloseTo(49.5175, 6) - }) - - it('klemmt Werte außerhalb der Strecke auf Anfang und Ende', () => { - expect(positionAtDistance(LINE, DISTANCES, -100)).toEqual(LINE[0]) - expect(positionAtDistance(LINE, DISTANCES, 99999)).toEqual(LINE[2]) - }) - - it('liefert null ohne Distanztabelle', () => { - expect(positionAtDistance(LINE, [], 500)).toBeNull() - }) -}) - -describe('Zusammenspiel mit parseGpx', () => { - it('führt Projektion und Rückweg auf dieselbe Stelle', () => { - const parsed = parseGpx(SAMPLE) - const coords = parsed.geojson.coordinates - - // Ein Punkt leicht neben der Mitte des zweiten Segments - const pos = nearestOnLine(coords, coords[1][0] + 0.0002, coords[1][1])! - const d = distanceAlong(pos, parsed.coordDistances)! - const back = positionAtDistance(coords, parsed.coordDistances, d)! - - expect(back[0]).toBeCloseTo(pos.lng, 4) - expect(back[1]).toBeCloseTo(pos.lat, 4) - }) -}) diff --git a/frontend/src/lib/gpx.ts b/frontend/src/lib/gpx.ts deleted file mode 100644 index 5f8e275..0000000 --- a/frontend/src/lib/gpx.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * GPX-Verarbeitung im Browser. - * - * Das Backend bleibt reines PocketBase ohne Hooks: Beim Upload liest das - * Frontend die Datei, rechnet Länge, Höhenmeter und Bounding-Box aus und - * schickt Originaldatei plus abgeleitete Werte gemeinsam an die API. - */ - -export type TrackPoint = { lat: number; lng: number; ele: number | null } - -export type ParsedGpx = { - points: TrackPoint[] - geojson: { type: 'LineString'; coordinates: [number, number][] } - /** Kumulative Distanz je Koordinate in `geojson.coordinates`, in Metern. */ - coordDistances: number[] - elevation: { d: number; ele: number }[] - bounds: [[number, number], [number, number]] - distance_m: number - ascent_m: number -} - -/** Erdradius in Metern */ -const R = 6_371_000 - -/** - * Anstiege unterhalb dieser Schwelle gelten als GPS-Rauschen. Ohne sie - * summieren sich Messfehler zu absurden Höhenmetern — ein flacher Waldweg - * käme leicht auf mehrere hundert Meter. - */ -const ELE_NOISE_M = 3 - -/** Distanz zweier Punkte in Metern (Haversine). */ -export function haversine(a: TrackPoint, b: TrackPoint): number { - const toRad = (d: number) => (d * Math.PI) / 180 - const dLat = toRad(b.lat - a.lat) - const dLng = toRad(b.lng - a.lng) - const lat1 = toRad(a.lat) - const lat2 = toRad(b.lat) - - const h = - Math.sin(dLat / 2) ** 2 + - Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2) - - return 2 * R * Math.asin(Math.sqrt(h)) -} - -/** - * Douglas-Peucker: entfernt Punkte, die kaum von der Verbindungslinie ihrer - * Nachbarn abweichen. Die Toleranz wird verdoppelt, bis das Ergebnis unter - * maxPoints liegt — so bleibt die Form erhalten, während die Datenmenge - * beherrschbar wird. - */ -export function simplify(points: TrackPoint[], maxPoints = 2000): TrackPoint[] { - return simplifyIndices(points, maxPoints).map((i) => points[i]) -} - -/** - * Wie `simplify`, liefert aber die Indizes der erhaltenen Punkte statt der - * Punkte selbst. Damit lässt sich zu jeder vereinfachten Koordinate die - * ursprüngliche Distanz nachschlagen. - */ -export function simplifyIndices(points: TrackPoint[], maxPoints = 2000): number[] { - if (points.length <= maxPoints) { - return points.map((_, i) => i) - } - - let tolerance = 0.00001 - let out = points.map((_, i) => i) - - for (let i = 0; i < 30 && out.length > maxPoints; i++) { - out = douglasPeuckerIndices(points, points.map((_, k) => k), tolerance) - tolerance *= 2 - } - - return out -} - -function douglasPeuckerIndices(all: TrackPoint[], idx: number[], tolerance: number): number[] { - if (idx.length < 3) return idx - - let maxDist = 0 - let split = 0 - - for (let i = 1; i < idx.length - 1; i++) { - const d = perpendicularDistance(all[idx[i]], all[idx[0]], all[idx[idx.length - 1]]) - if (d > maxDist) { - maxDist = d - split = i - } - } - - if (maxDist <= tolerance) return [idx[0], idx[idx.length - 1]] - - const left = douglasPeuckerIndices(all, idx.slice(0, split + 1), tolerance) - const right = douglasPeuckerIndices(all, idx.slice(split), tolerance) - return [...left.slice(0, -1), ...right] -} - -/** - * Abstand eines Punktes zur Geraden durch start und end — in Grad, nicht in - * Metern. Für den Vergleich innerhalb eines Tracks genügt das und spart die - * teure Projektion. - */ -function perpendicularDistance(p: TrackPoint, start: TrackPoint, end: TrackPoint): number { - const dx = end.lng - start.lng - const dy = end.lat - start.lat - - if (dx === 0 && dy === 0) { - return Math.hypot(p.lng - start.lng, p.lat - start.lat) - } - - const t = - ((p.lng - start.lng) * dx + (p.lat - start.lat) * dy) / (dx * dx + dy * dy) - const clamped = Math.max(0, Math.min(1, t)) - - return Math.hypot( - p.lng - (start.lng + clamped * dx), - p.lat - (start.lat + clamped * dy), - ) -} - -/** - * Liest eine GPX-Datei. Wirft mit einer für Nutzer verständlichen Meldung, - * wenn die Datei kein gültiges XML ist oder keine Punkte enthält. - */ -export function parseGpx(xml: string): ParsedGpx { - const doc = new DOMParser().parseFromString(xml, 'application/xml') - - if (doc.querySelector('parsererror')) { - throw new Error('Die Datei ist kein gültiges XML.') - } - - // Manche Programme exportieren Routen (rtept) statt Tracks (trkpt). - let nodes = Array.from(doc.getElementsByTagName('trkpt')) - if (nodes.length === 0) { - nodes = Array.from(doc.getElementsByTagName('rtept')) - } - - if (nodes.length === 0) { - throw new Error('Die GPX-Datei enthält keine Punkte.') - } - - const points: TrackPoint[] = [] - for (const n of nodes) { - const latAttr = n.getAttribute('lat') - const lngAttr = n.getAttribute('lon') - // Number(null) ist 0, nicht NaN — ohne diese Prüfung landen Punkte - // ohne Koordinaten bei 0/0 im Golf von Guinea und verfälschen - // Streckenlänge und Bounding-Box. - if (latAttr === null || lngAttr === null) continue - - const lat = Number(latAttr) - const lng = Number(lngAttr) - if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue - - const eleText = n.getElementsByTagName('ele')[0]?.textContent - const ele = eleText != null && eleText.trim() !== '' ? Number(eleText) : null - - points.push({ lat, lng, ele: Number.isFinite(ele as number) ? ele : null }) - } - - if (points.length === 0) { - throw new Error('Die GPX-Datei enthält keine Punkte.') - } - - // Länge, Höhenprofil und Höhenmeter in einem Durchlauf - let distance = 0 - let ascent = 0 - const elevation: { d: number; ele: number }[] = [] - let lastCountedEle: number | null = points[0].ele - - // Kumulative Distanz je Originalpunkt — Brücke zwischen elevation (ein - // Eintrag pro Originalpunkt) und der vereinfachten Geometrie. - const distAt: number[] = new Array(points.length) - distAt[0] = 0 - - if (points[0].ele != null) elevation.push({ d: 0, ele: points[0].ele }) - - for (let i = 1; i < points.length; i++) { - distance += haversine(points[i - 1], points[i]) - distAt[i] = distance - - const ele = points[i].ele - if (ele == null) continue - - elevation.push({ d: Math.round(distance), ele }) - - if (lastCountedEle == null) { - lastCountedEle = ele - } else { - const delta = ele - lastCountedEle - if (delta >= ELE_NOISE_M) { - ascent += delta - lastCountedEle = ele - } else if (delta <= -ELE_NOISE_M) { - // Abstieg über Schwelle: neuer Bezugspunkt, aber nicht gezählt - lastCountedEle = ele - } - // Kleine Schwankungen (unter 3 m) ignorieren, Bezugspunkt bleibt - } - } - - const lats = points.map((p) => p.lat) - const lngs = points.map((p) => p.lng) - - const keep = simplifyIndices(points) - const simplified = keep.map((i) => points[i]) - - return { - points: simplified, - geojson: { - type: 'LineString', - coordinates: simplified.map((p) => [p.lng, p.lat] as [number, number]), - }, - coordDistances: keep.map((i) => Math.round(distAt[i])), - elevation, - bounds: [ - [Math.min(...lngs), Math.min(...lats)], - [Math.max(...lngs), Math.max(...lats)], - ], - distance_m: Math.round(distance), - ascent_m: Math.round(ascent), - } -} - -/** Ein auf die Trail-Linie projizierter Punkt. */ -export type LinePosition = { - lng: number - lat: number - /** Index des Segmentanfangs in `coordinates` */ - index: number - /** Lage im Segment zwischen 0 und 1 */ - t: number -} - -/** - * Projiziert einen beliebigen Punkt auf die nächstgelegene Stelle der Linie. - * Damit landet ein Marker exakt auf dem Track, egal wie genau geklickt wurde. - * - * Gerechnet wird in Grad, die Längengrade aber mit cos(lat) gestaucht — sonst - * wäre ein Grad Ost-West bei uns fast doppelt so „lang" wie einer Nord-Süd und - * die Projektion würde spürbar daneben liegen. Für die paar Meter Suchradius - * genügt das; eine echte Projektion wäre hier Aufwand ohne Gewinn. - */ -export function nearestOnLine( - coords: [number, number][], - lng: number, - lat: number, -): LinePosition | null { - if (!coords.length) return null - if (coords.length === 1) { - return { lng: coords[0][0], lat: coords[0][1], index: 0, t: 0 } - } - - const kx = Math.cos((lat * Math.PI) / 180) || 1 - - let best: LinePosition | null = null - let bestDist = Infinity - - for (let i = 0; i < coords.length - 1; i++) { - const [ax, ay] = coords[i] - const [bx, by] = coords[i + 1] - - const dx = (bx - ax) * kx - const dy = by - ay - const px = (lng - ax) * kx - const py = lat - ay - - const len = dx * dx + dy * dy - // Doppelter Punkt im Track: das Segment hat keine Richtung. - const t = len === 0 ? 0 : Math.max(0, Math.min(1, (px * dx + py * dy) / len)) - - const ox = px - t * dx - const oy = py - t * dy - const dist = ox * ox + oy * oy - - if (dist < bestDist) { - bestDist = dist - best = { lng: ax + (bx - ax) * t, lat: ay + (by - ay) * t, index: i, t } - } - } - - return best -} - -/** - * Streckenkilometer einer projizierten Stelle. `coordDistances` hat einen - * Eintrag je Koordinate; zwischen zwei Stützpunkten wird linear interpoliert. - * Ohne Distanztabelle (Versionen von vor dem Feld) gibt es kein Ergebnis. - */ -export function distanceAlong(pos: LinePosition, coordDistances: number[]): number | null { - if (coordDistances.length <= pos.index) return null - - const a = coordDistances[pos.index] - const b = coordDistances[pos.index + 1] - if (b == null) return a - - return a + (b - a) * pos.t -} - -/** - * Gegenrichtung zu `distanceAlong`: die Koordinate zu einem Streckenkilometer. - * Ebenfalls interpoliert, damit ein Punkt beim Überfahren gleichmäßig wandert - * statt von Stützpunkt zu Stützpunkt zu springen. - */ -export function positionAtDistance( - coords: [number, number][], - coordDistances: number[], - distance: number, -): [number, number] | null { - if (!coords.length || coordDistances.length < 2) return null - - const last = Math.min(coords.length, coordDistances.length) - 1 - if (distance <= coordDistances[0]) return coords[0] - if (distance >= coordDistances[last]) return coords[last] - - // Binäre Suche nach dem Segment, in dem die Distanz liegt. - let lo = 0 - let hi = last - while (hi - lo > 1) { - const mid = (lo + hi) >> 1 - if (coordDistances[mid] <= distance) lo = mid - else hi = mid - } - - const span = coordDistances[hi] - coordDistances[lo] - const t = span === 0 ? 0 : (distance - coordDistances[lo]) / span - - return [ - coords[lo][0] + (coords[hi][0] - coords[lo][0]) * t, - coords[lo][1] + (coords[hi][1] - coords[lo][1]) * t, - ] -} diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/frontend/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/stores/app.svelte.ts b/frontend/src/lib/stores/app.svelte.ts deleted file mode 100644 index 8758b99..0000000 --- a/frontend/src/lib/stores/app.svelte.ts +++ /dev/null @@ -1,205 +0,0 @@ -import 'tippy.js/animations/shift-toward-subtle.css' -import 'tippy.js/dist/tippy.css' -import tippy from 'tippy.js' // optional -import type {ClientResponseError} from 'pocketbase' - -class AppStore { - theme: string = $state('dark') - - showMobileNavigation: boolean = $state(false) - seo = $state({ - title: 'Title', - description: 'Description', - }) - - // meta: { - // customer: CustomersResponse | undefined - // site: SitesResponse | undefined - // record: RecordsResponse | undefined - // contact: ContactsResponse | undefined - // file: FilesResponse | undefined - // user: UsersResponse | undefined - // } = $state({ - // customer: undefined, - // site: undefined, - // record: undefined, - // contact: undefined, - // file: undefined, - // user: undefined, - // }) - - showGlobalActionMenu = $state(false) - - confirm: { - show: boolean - title: string - text: string - error: boolean | ClientResponseError - yes: () => void - no: () => void - request: (props: any) => void - close: () => void - } = $state({ - show: false, - title: '', - text: '', - error: false, - yes: () => { - }, - no: () => { - }, - - request: (props: any) => { - this.confirm = {...this.confirm, ...props} - this.confirm.show = true - }, - - close: () => { - this.confirm.show = false - this.confirm.title = '' - this.confirm.text = '' - }, - - }) - - - /** - - Global Hotkey handler - - Ignores hotkey handling when focus of user is in generic input-tags or [contenteditable] - - @example - - - - - */ - hotkey = (event: KeyboardEvent, condition: boolean, callback: Function) => { - - // Sicherstellen, dass `event` und `event.target` vorhanden sind - if (!event || !event.target) { - return - } - - // Liste ignorierter Tag-Namen - const ignoredTags = ['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'] - - // Standard-TAG-Prüfung mit optionaler Erweiterung - const isIgnoredTag = ignoredTags.includes((event.target as HTMLElement).tagName?.toUpperCase()) - - // Fokusierbare oder editierbare Inhalte ignorieren - const isEditable = - (event.target as HTMLElement).isContentEditable || // `contenteditable=true` - (event.target as HTMLElement).hasAttribute('contenteditable') // Attribut prüfen für dynamische Inhalte - - if (isIgnoredTag || isEditable) { - // Rücksprung, keine Aktion ausführen - return - } - - // Bedingung und Callback ausführen - if (condition) { - event.preventDefault() // Standardaktion abbrechen - callback() // Callback-Funktion ausführen - } - } - - toggleTheme = () => { - this.theme = this.theme === 'dark' ? 'light' : 'dark' - } -} - -export const app = new AppStore() - - -export const utils = { - - getFileSize: async (fileUrl: string): Promise => { - try { - const response = await fetch(fileUrl, {method: 'HEAD'}) - const contentLength = response.headers.get('content-length') - const bytes = contentLength ? parseInt(contentLength, 10) : 0 - const k = 1024 - const sizes = ['Bytes', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - if (bytes === 0) return '0 Bytes' - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } catch (error) { - console.error('Error fetching file size:', error) - return 'Unbekannt' - } - }, - - - sanitize: (input: string) => { - return input.replace(/<\/?("[^"]*"|'[^']*'|[^>])*(>|$)/g, '') - }, - - - excerpt: (input: string, length: number = 20) => { - if (input.length <= 20) return input - return input.substring(0, length) + '…' - }, - - slugify: (input: string) => { - if (!input) return '' - // make lower case and trim - var slug = input.toLowerCase().trim() - // remove accents from charaters - slug = slug.normalize('NFD').replace(/[\u0300-\u036f]/g, '') - // replace invalid chars with spaces - slug = slug.replace(/[^a-z0-9\s-]/g, ' ').trim() - // replace multiple spaces or hyphens with a single hyphen - slug = slug.replace(/[\s-]+/g, '-') - return slug - }, -} - -/** - * Tooltip für ein Element. Appweit die einzige Art, einen Hinweis an einen - * Knopf zu hängen — `title` überlassen wir dem Browser nicht mehr: Es - * erscheint erst nach einer Sekunde, sieht auf jedem System anders aus und - * lässt sich auf Touch-Geräten gar nicht aufrufen. - * - * Die Action beherrscht Aktualisieren und Aufräumen: Ein Text, der sich - * ändert (»Erledigt« / »Wieder öffnen«), muss auch im Tooltip wechseln, und - * eine Instanz, die ein entferntes Element überlebt, hinge als leere Blase - * im Dokument. - */ -export const tooltip = ( - element: HTMLElement, - props: { active?: boolean; content?: string; showOnCreate?: boolean } = {}, -) => { - const options = (p: typeof props) => ({ - animation: 'shift-toward-subtle', - allowHTML: false, - content: p.content ?? '', - showOnCreate: p.showOnCreate, - }) - - const wanted = (p: typeof props) => p.active !== false && !!p.content?.trim() - - let instance = wanted(props) ? tippy(element, options(props)) : null - - return { - update(next: typeof props) { - if (!wanted(next)) { - instance?.destroy() - instance = null - return - } - - if (instance) instance.setProps(options(next)) - else instance = tippy(element, options(next)) - }, - destroy() { - instance?.destroy() - instance = null - }, - } -} diff --git a/frontend/src/lib/stores/clockSync.svelte.ts b/frontend/src/lib/stores/clockSync.svelte.ts deleted file mode 100644 index 4c5d9e8..0000000 --- a/frontend/src/lib/stores/clockSync.svelte.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { api } from './pocketbase.svelte' - -const KEY = 'clockOffset' - -/** - * Ab dieser Abweichung ist eine Messung nicht mehr gut genug für eine Stage. - * 250 ms, weil die Uhr auf Zehntel angezeigt wird und ein Fehler, der die - * angezeigte Stelle überschreitet, in der Rangliste sichtbar wird. - */ -export const ACCURACY_LIMIT_MS = 250 - -/** - * Nach dieser Zeit gilt eine Messung als alt. Telefonuhren driften langsam - * (Größenordnung Sekunden pro Woche), aber ein Gerät kann zwischendurch in - * den Flugmodus, den Akku leeren oder die Zeitzone wechseln — nach vier - * Stunden lohnt der erneute Abgleich, er dauert eine Sekunde. - */ -export const STALE_AFTER_MS = 4 * 60 * 60 * 1000 - -type Stored = { offset: number; measuredAt: number; accuracy: number } - -/** - * Der Uhrenabgleich zwischen diesem Gerät und dem Server. - * - * Gemessen wird nach Cristian: Zeitpunkt der Anfrage merken (t0), Serverzeit - * holen (T), Zeitpunkt der Antwort merken (t1). Unterwegs war die Antwort - * ungefähr die halbe Laufzeit, die Serverzeit im Moment t1 ist also etwa - * T + (t1 − t0) / 2, und der Versatz dieses Geräts ist die Differenz dazu. - * - * Das Verfahren ist nur so gut wie die Symmetrie der Strecke: Dauert der Weg - * hin länger als zurück, wandert der Schätzwert. Deshalb mehrere Proben und - * der Median über die mit der kürzesten Laufzeit — kurze Laufzeit heißt wenig - * Spielraum für Asymmetrie. Die halbe Laufzeit der besten Probe ist zugleich - * die Obergrenze des Fehlers und wird als `accuracy` mitgeführt. - * - * Bewusst kein Store je Team und kein Kontext: Der Versatz gehört dem Gerät, - * nicht der Anmeldung, und wird deshalb im localStorage gehalten und beim - * Start wieder aufgenommen. - */ -export class ClockSync { - /** Millisekunden, die zur Gerätezeit addiert werden müssen. */ - offset = $state(0) - /** Wann zuletzt gemessen wurde (Gerätezeit). */ - measuredAt = $state(null) - /** Geschätzte Obergrenze des verbleibenden Fehlers in Millisekunden. */ - accuracy = $state(null) - busy = $state(false) - error = $state(null) - - constructor() { - if (typeof localStorage === 'undefined') return - - try { - const raw = localStorage.getItem(KEY) - if (!raw) return - - const stored = JSON.parse(raw) as Stored - if (typeof stored.offset !== 'number' || !Number.isFinite(stored.offset)) return - - this.offset = stored.offset - this.measuredAt = stored.measuredAt ?? null - this.accuracy = stored.accuracy ?? null - } catch { - // Kaputter Eintrag ist wie kein Eintrag: ohne Abgleich weiter. - } - } - - /** Die abgeglichene Gegenwart in Millisekunden. */ - now(): number { - return Date.now() + this.offset - } - - /** Dieselbe Zeit als ISO-String — die Form, in der PocketBase sie will. */ - iso(at: number = this.now()): string { - return new Date(at).toISOString() - } - - /** Alter der Messung in Millisekunden, null wenn nie gemessen. */ - get age(): number | null { - return this.measuredAt === null ? null : Date.now() - this.measuredAt - } - - get measured(): boolean { - return this.measuredAt !== null - } - - get stale(): boolean { - const age = this.age - return age === null || age > STALE_AFTER_MS - } - - /** Ist die letzte Messung gut genug, um damit eine Stage zu nehmen? */ - get trustworthy(): boolean { - return this.measured && !this.stale && (this.accuracy ?? Infinity) <= ACCURACY_LIMIT_MS - } - - /** - * Gegen den Server abgleichen. `rounds` Proben, die schlechteren fliegen - * raus; scheitert jede, bleibt der bisherige Versatz stehen — ein alter - * Wert ist besser als gar keiner. - */ - async measure(rounds = 7): Promise { - if (this.busy) return - - this.busy = true - this.error = null - - const samples: { offset: number; rtt: number }[] = [] - - try { - for (let i = 0; i < rounds; i++) { - const t0 = Date.now() - - const res = await fetch(`${api.baseURL}/api/clock`, { cache: 'no-store' }) - if (!res.ok) throw new Error(`Server antwortet mit ${res.status}`) - - const t1 = Date.now() - const body = (await res.json()) as { now?: number } - if (typeof body.now !== 'number') throw new Error('Antwort ohne Zeit') - - const rtt = t1 - t0 - samples.push({ offset: body.now + rtt / 2 - t1, rtt }) - } - - if (samples.length === 0) throw new Error('Keine Messung zustande gekommen') - - // Die schnellste Hälfte zählt: Wenig Laufzeit heißt wenig Raum für - // eine ungleiche Verteilung zwischen Hin- und Rückweg. - samples.sort((a, b) => a.rtt - b.rtt) - const best = samples.slice(0, Math.max(1, Math.ceil(samples.length / 2))) - - const offsets = best.map((s) => s.offset).sort((a, b) => a - b) - const mid = Math.floor(offsets.length / 2) - const median = - offsets.length % 2 === 1 ? offsets[mid] : (offsets[mid - 1] + offsets[mid]) / 2 - - this.offset = Math.round(median) - this.accuracy = Math.round(best[0].rtt / 2) - this.measuredAt = Date.now() - this.persist() - } catch (e: any) { - this.error = e?.message ?? 'Der Abgleich ist fehlgeschlagen.' - } finally { - this.busy = false - } - } - - private persist() { - if (typeof localStorage === 'undefined') return - if (this.measuredAt === null || this.accuracy === null) return - - try { - const stored: Stored = { - offset: this.offset, - measuredAt: this.measuredAt, - accuracy: this.accuracy, - } - localStorage.setItem(KEY, JSON.stringify(stored)) - } catch { - // Kein Speicher (privates Fenster, volle Quote): Der Versatz gilt - // dann nur für diese Sitzung. Besser als gar kein Abgleich. - } - } -} - -/** - * Eine Instanz für die ganze App. Der Versatz gehört dem Gerät — ein zweiter - * Abgleich in einer zweiten Komponente wäre nicht nur überflüssig, er würde - * sich mit dem ersten widersprechen. - */ -export const clockSync = new ClockSync() diff --git a/frontend/src/lib/stores/connection.svelte.ts b/frontend/src/lib/stores/connection.svelte.ts deleted file mode 100644 index 294aba4..0000000 --- a/frontend/src/lib/stores/connection.svelte.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { api } from './pocketbase.svelte' -import { timeOutbox } from './timeOutbox.svelte' - -/** Abstand der Prüfungen, solange die Verbindung steht. */ -const INTERVAL_ONLINE = 30_000 - -/** - * Abstand, solange sie fehlt. Enger, weil die Rückkehr die Nachricht ist: - * Erst dann gehen die Zeiten aus dem Ausgang raus. - */ -const INTERVAL_OFFLINE = 5_000 - -/** Nach dieser Zeit gilt eine Prüfung als gescheitert. */ -const TIMEOUT = 6_000 - -/** - * Steht die Verbindung zum Server? - * - * `navigator.onLine` allein taugt dafür nicht: Es sagt nur, dass das Gerät - * irgendein Netz hat — im WLAN einer Hütte ohne Uplink, im Funkloch mit einem - * Balken, hinter einem Portal, das jede Anfrage abfängt, steht es unbeirrt auf - * `true`. Für die Zeitnahme zählt aber nur eine Frage: Kommt eine Anfrage beim - * Server an? Also wird genau das gefragt, in regelmäßigen Abständen. - * - * Umgekehrt ist `navigator.onLine === false` verlässlich — kein Netz heißt - * kein Server. Das kürzt eine Prüfung ab, die ohnehin scheitern würde. - */ -export class Connection { - /** Letzter Stand: Server erreichbar? Bis zur ersten Prüfung optimistisch. */ - online = $state(true) - /** Läuft gerade eine Prüfung? */ - checking = $state(false) - /** Wann zuletzt eine Anfrage ankam. */ - lastOk = $state(null) - /** Seit wann die Verbindung fehlt — für „seit 3 Minuten offline". */ - offlineSince = $state(null) - - private timer: ReturnType | null = null - private started = false - - /** - * Prüfung starten. Läuft, bis `stop()` kommt — die Verbindung ist nichts, - * was eine einzelne Seite beträfe. - */ - start() { - if (this.started || typeof window === 'undefined') return - this.started = true - - window.addEventListener('online', this.onBrowserOnline) - window.addEventListener('offline', this.onBrowserOffline) - // Zurück aus dem Hintergrund: Zwischen Sperrbildschirm und Wiedersehen - // kann alles passiert sein, und die Timer standen womöglich still. - document.addEventListener('visibilitychange', this.onVisible) - - void this.check() - } - - stop() { - if (!this.started) return - this.started = false - - window.removeEventListener('online', this.onBrowserOnline) - window.removeEventListener('offline', this.onBrowserOffline) - document.removeEventListener('visibilitychange', this.onVisible) - - if (this.timer) clearTimeout(this.timer) - this.timer = null - } - - private onBrowserOnline = () => void this.check() - private onBrowserOffline = () => this.setState(false) - private onVisible = () => { - if (document.visibilityState === 'visible') void this.check() - } - - /** Wie lange die Verbindung schon fehlt, in Millisekunden. */ - get offlineFor(): number | null { - return this.offlineSince === null ? null : Date.now() - this.offlineSince - } - - /** - * Einmal nachsehen. Gibt zurück, ob der Server geantwortet hat, damit ein - * Aufrufer („jetzt erneut versuchen") das Ergebnis auswerten kann. - */ - async check(): Promise { - if (typeof navigator !== 'undefined' && !navigator.onLine) { - this.setState(false) - this.schedule() - return false - } - - this.checking = true - const controller = new AbortController() - const abort = setTimeout(() => controller.abort(), TIMEOUT) - - try { - const res = await fetch(`${api.baseURL}/api/health`, { - cache: 'no-store', - signal: controller.signal, - }) - this.setState(res.ok) - return res.ok - } catch { - this.setState(false) - return false - } finally { - clearTimeout(abort) - this.checking = false - this.schedule() - } - } - - private setState(online: boolean) { - const was = this.online - this.online = online - - if (online) { - this.lastOk = Date.now() - this.offlineSince = null - - // Die Rückkehr ist der Moment, in dem der Ausgang leer werden - // kann. Das Browser-Ereignis `online` allein feuert dafür zu früh: - // Es meldet die Netzwerkschnittstelle, nicht den Server. - if (!was && timeOutbox.pending > 0) void timeOutbox.flush() - } else if (was || this.offlineSince === null) { - this.offlineSince = Date.now() - } - } - - private schedule() { - if (!this.started) return - if (this.timer) clearTimeout(this.timer) - this.timer = setTimeout( - () => void this.check(), - this.online ? INTERVAL_ONLINE : INTERVAL_OFFLINE, - ) - } -} - -/** Eine Verbindung, eine Instanz — sie gehört dem Gerät, nicht einer Seite. */ -export const connection = new Connection() diff --git a/frontend/src/lib/stores/eventParticipants.svelte.ts b/frontend/src/lib/stores/eventParticipants.svelte.ts deleted file mode 100644 index 86ff826..0000000 --- a/frontend/src/lib/stores/eventParticipants.svelte.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api, auth } from './pocketbase.svelte' -import { getEventContext } from './events.svelte' -import { getRiderContext, riderName } from './riders.svelte' -import type { EventParticipantsResponse, RidersResponse } from '$lib/types' - -const KEY = Symbol('eventParticipant') - -export type ParticipantStatus = 'angefragt' | 'zugesagt' | 'abgesagt' - -export const PARTICIPANT_STATUS_LABEL: Record = { - angefragt: 'Angefragt', - zugesagt: 'Dabei', - abgesagt: 'Abgesagt', -} - -/** - * Teilnehmer eines Events. Fahrer gehören dem Team, nicht dem Event — wer bei - * einem Termin dabei ist, steht hier. Die Startnummer hängt ebenfalls hier, - * denn sie gilt je Event: Derselbe Fahrer kann in zwei Rennen zwei Nummern - * haben, und beim Stammtisch gar keine. - */ -export class EventParticipantStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private events = getEventContext() - private riders = getRiderContext() - - /** Nur Teilnehmer von Events des aktiven Teams. */ - get scoped(): EventParticipantsResponse[] { - const eventIds = new Set(this.events.scoped.map((e) => e.id)) - return this.records.filter((r) => eventIds.has(r.event)) - } - - /** Teilnehmer eines Events, nach Startnummer und dann nach Name. */ - byEvent(eventId: string): EventParticipantsResponse[] { - return this.records - .filter((r) => r.event === eventId) - .sort((a, b) => { - const na = parseInt(a.number ?? '', 10) - const nb = parseInt(b.number ?? '', 10) - if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb - if (Number.isFinite(na)) return -1 - if (Number.isFinite(nb)) return 1 - return this.nameOf(a.rider).localeCompare(this.nameOf(b.rider)) - }) - } - - /** Nur die Zugesagten — wer wirklich am Start steht. */ - confirmedByEvent(eventId: string): EventParticipantsResponse[] { - return this.byEvent(eventId).filter((r) => r.status === 'zugesagt') - } - - byRider(riderId: string): EventParticipantsResponse[] { - return this.scoped.filter((r) => r.rider === riderId) - } - - get(eventId: string, riderId: string): EventParticipantsResponse | undefined { - return this.records.find((r) => r.event === eventId && r.rider === riderId) - } - - /** Startnummer eines Fahrers bei einem Event, falls vergeben. */ - numberOf(eventId: string, riderId: string | undefined): string { - if (!riderId) return '' - return this.get(eventId, riderId)?.number ?? '' - } - - /** - * Startnummern sind je Event eindeutig — zwei gleiche Nummern in einem - * Rennen wären an der Strecke nicht auseinanderzuhalten. - */ - isNumberTaken(eventId: string, number: string, excludeId?: string): boolean { - const wanted = number.trim() - if (!wanted) return false - - return this.byEvent(eventId).some( - (r) => (r.number ?? '').trim() === wanted && r.id !== excludeId, - ) - } - - /** Teilnahme des eingeloggten Nutzers, sofern sein Konto an einem Fahrer hängt. */ - own(eventId: string): EventParticipantsResponse | undefined { - const uid = auth.user?.id - if (!uid) return undefined - - const mine = new Set(this.riders.getByUser(uid).map((r) => r.id)) - return this.byEvent(eventId).find((p) => mine.has(p.rider)) - } - - /** „#12 Max Mustermann" — ohne Nummer nur der Name. */ - riderLabel(eventId: string, riderId: string | undefined): string { - if (!riderId) return 'Unbekannter Fahrer' - - const name = this.nameOf(riderId) || 'Unbekannter Fahrer' - const number = this.numberOf(eventId, riderId) - - return number ? `#${number} ${name}` : name - } - - private nameOf(riderId: string): string { - const r = this.riders.getById(riderId) - return r ? riderName(r) : '' - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('event_participants').getFullList({ - sort: '+created', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Teilnehmer' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('event_participants').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('event_participants subscribe failed:', e)) - } - - async add(data: { - event: string - rider: string - number?: string - status?: ParticipantStatus - }) { - return await api.collection('event_participants').create({ - status: 'zugesagt' as ParticipantStatus, - ...data, - created_by: auth.user?.id, - }) - } - - async edit(id: string, data: Partial) { - return await api.collection('event_participants').update(id, data) - } - - async setStatus(id: string, status: ParticipantStatus) { - return await this.edit(id, { status } as Partial) - } - - async remove(id: string) { - await api.collection('event_participants').delete(id) - } - - /** Fahrer des Teams, die bei diesem Event noch fehlen. */ - available(eventId: string): RidersResponse[] { - const taken = new Set(this.byEvent(eventId).map((r) => r.rider)) - return this.riders.scoped.filter((r) => !taken.has(r.id)) - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setEventParticipantContext() { - const store = new EventParticipantStore() - setContext(KEY, store) - return store -} - -export function getEventParticipantContext(): EventParticipantStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/eventSeries.svelte.ts b/frontend/src/lib/stores/eventSeries.svelte.ts deleted file mode 100644 index 980b827..0000000 --- a/frontend/src/lib/stores/eventSeries.svelte.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api, auth } from './pocketbase.svelte' -import { getTeamContext } from './teams.svelte' -import { getEventContext } from './events.svelte' -import { dayOf, todayISO } from '$lib/time' -import type { EventSeriesResponse } from '$lib/types' - -const KEY = Symbol('eventSeries') - -export type SeriesRule = 'woechentlich' | 'zweiwoechentlich' | 'monatlich' - -export const SERIES_RULE_LABEL: Record = { - woechentlich: 'wöchentlich', - zweiwoechentlich: 'alle zwei Wochen', - monatlich: 'monatlich', -} - -/** Wie viele Termine ein Erzeugen-Lauf höchstens anlegt. */ -const BATCH = 12 - -/** - * Nächste Termine einer Regel ab einem Startdatum. - * - * Gerechnet wird auf Kalendertagen, nicht auf Millisekunden: Über Sommer- und - * Winterzeit hinweg verschiebt sich eine Woche sonst um eine Stunde und ein - * Termin kippt auf den Vortag. - */ -export function occurrences( - startDay: string, - rule: SeriesRule, - count: number, - untilDay?: string, -): string[] { - if (!startDay) return [] - - const [y, m, d] = startDay.split('-').map(Number) - const out: string[] = [] - let cursor = new Date(Date.UTC(y, m - 1, d)) - - for (let i = 0; i < count; i++) { - const iso = cursor.toISOString().slice(0, 10) - if (untilDay && iso > untilDay) break - out.push(iso) - - if (rule === 'monatlich') { - cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate())) - } else { - const step = rule === 'zweiwoechentlich' ? 14 : 7 - cursor = new Date(cursor.getTime() + step * 24 * 60 * 60 * 1000) - } - } - - return out -} - -export class EventSeriesStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - private events = getEventContext() - - get scoped(): EventSeriesResponse[] { - const teamId = this.teams.activeId - if (!teamId) return [] - return this.records.filter((r) => r.team === teamId) - } - - getById(id: string) { - return this.records.find((r) => r.id === id) - } - - /** Termine, die aus dieser Serie entstanden sind. */ - eventsOf(seriesId: string) { - return this.events.scoped.filter((e) => e.series === seriesId) - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('event_series').getFullList({ - sort: '+name', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Serien' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('event_series').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('event_series subscribe failed:', e)) - } - - async create(data: Partial) { - const teamId = this.teams.activeId - if (!teamId) throw new Error('Kein aktives Team') - - return await api.collection('event_series').create({ - ...data, - team: teamId, - created_by: auth.user?.id, - }) - } - - async edit(id: string, data: Partial) { - return await api.collection('event_series').update(id, data) - } - - async remove(id: string) { - await api.collection('event_series').delete(id) - } - - /** - * Legt die nächsten fälligen Termine an. Vorhandene bleiben unberührt — - * erzeugt wird nur, was fehlt, damit ein zweiter Klick nichts verdoppelt. - * Zurück kommt die Zahl der neuen Termine. - */ - async generate(seriesId: string): Promise { - const series = this.getById(seriesId) - if (!series) throw new Error('Serie nicht gefunden') - if (!series.starts) throw new Error('Die Serie hat kein Startdatum.') - - const existing = new Set(this.eventsOf(seriesId).map((e) => dayOf(e.starts))) - const today = todayISO() - - // Vergangene Termine nachträglich anzulegen hilft niemandem. - const dates = occurrences( - dayOf(series.starts), - (series.rule || 'woechentlich') as SeriesRule, - BATCH * 4, - dayOf(series.until) || undefined, - ).filter((d) => d >= today && !existing.has(d)) - - let created = 0 - for (const day of dates.slice(0, BATCH)) { - await api.collection('events').create({ - name: series.name, - description: series.description, - location: series.location, - starts: day, - timing: !!series.timing, - participation: series.participation || 'offen', - series: series.id, - team: series.team, - created_by: auth.user?.id, - }) - created++ - } - - return created - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setEventSeriesContext() { - const store = new EventSeriesStore() - setContext(KEY, store) - return store -} - -export function getEventSeriesContext(): EventSeriesStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/eventSeries.test.ts b/frontend/src/lib/stores/eventSeries.test.ts deleted file mode 100644 index 27d0d46..0000000 --- a/frontend/src/lib/stores/eventSeries.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { occurrences } from './eventSeries.svelte' - -describe('occurrences', () => { - it('zählt wöchentlich weiter', () => { - expect(occurrences('2026-09-02', 'woechentlich', 3)).toEqual([ - '2026-09-02', - '2026-09-09', - '2026-09-16', - ]) - }) - - it('zählt zweiwöchentlich weiter', () => { - expect(occurrences('2026-09-02', 'zweiwoechentlich', 3)).toEqual([ - '2026-09-02', - '2026-09-16', - '2026-09-30', - ]) - }) - - it('zählt monatlich weiter', () => { - expect(occurrences('2026-01-15', 'monatlich', 3)).toEqual([ - '2026-01-15', - '2026-02-15', - '2026-03-15', - ]) - }) - - it('bleibt über die Zeitumstellung hinweg auf demselben Wochentag', () => { - // Ende Oktober 2026 wird in Europa die Uhr zurückgestellt; über - // Millisekunden gerechnet kippte der Termin sonst auf den Vortag. - const days = occurrences('2026-10-21', 'woechentlich', 3) - expect(days).toEqual(['2026-10-21', '2026-10-28', '2026-11-04']) - - const weekdays = days.map((d) => new Date(`${d}T12:00:00Z`).getUTCDay()) - expect(new Set(weekdays).size).toBe(1) - }) - - it('hört beim Enddatum auf', () => { - expect(occurrences('2026-09-02', 'woechentlich', 10, '2026-09-20')).toEqual([ - '2026-09-02', - '2026-09-09', - '2026-09-16', - ]) - }) - - it('liefert nichts ohne Startdatum', () => { - expect(occurrences('', 'woechentlich', 5)).toEqual([]) - }) -}) diff --git a/frontend/src/lib/stores/events.svelte.ts b/frontend/src/lib/stores/events.svelte.ts deleted file mode 100644 index 8633e2c..0000000 --- a/frontend/src/lib/stores/events.svelte.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api } from './pocketbase.svelte' -import { app } from './app.svelte' -import { getTeamContext } from './teams.svelte' -import { dayOf, todayISO } from '$lib/time' -import type { EventsResponse } from '$lib/types' - -const KEY = Symbol('event') - -/** Wie man zu einem Event dazukommt. */ -export const PARTICIPATION_LABEL: Record = { - offen: 'Offen', - bestaetigung: 'Mit Bestätigung', - geschlossen: 'Geschlossen', -} - -export class EventStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - - /** Records gefiltert nach aktivem Team */ - get scoped(): EventsResponse[] { - const teamId = this.teams.activeId - if (!teamId) return [] - return this.records.filter((r) => r.team === teamId) - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('events').getFullList({ - sort: '-created', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Events' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('events').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('events subscribe failed:', e)) - } - - async create(data: Partial) { - const teamId = this.teams.activeId - if (!teamId) throw new Error('Kein aktives Team') - const rec = await api.collection('events').create({ ...data, team: teamId }) - return rec - } - - async edit(id: string, data: Partial) { - return await api.collection('events').update(id, data) - } - - remove(record: EventsResponse) { - return new Promise((resolve, reject) => { - app.confirm.request({ - title: 'Event löschen?', - text: `Möchten Sie das Event „${record.name || record.description}" wirklich löschen?`, - yes: async () => { - try { - await api.collection('events').delete(record.id) - app.confirm.close() - resolve() - } catch (e) { - app.confirm.close() - reject(e) - } - }, - no: () => { - app.confirm.close() - resolve() - }, - }) - }) - } - - getById(id: string) { - return this.records.find((r) => r.id === id) - } - - /** - * Nach Datum sortiert, das nächste zuerst. Events ohne Datum hängen - * hinten dran — sie sind noch nicht terminiert. - */ - get byDate(): EventsResponse[] { - return [...this.scoped].sort((a, b) => { - const da = dayOf(a.starts) - const db = dayOf(b.starts) - if (!da && !db) return 0 - if (!da) return 1 - if (!db) return -1 - return da < db ? -1 : da > db ? 1 : 0 - }) - } - - /** - * Läuft heute. Ohne Enddatum zählt der Starttag allein — genau dann ist - * ein eintägiges Event „heute". - */ - get active(): EventsResponse[] { - const today = todayISO() - return this.byDate.filter((r) => { - const from = dayOf(r.starts) - if (!from) return false - const to = dayOf(r.ends) || from - return from <= today && today <= to - }) - } - - /** Steht noch an. */ - get upcoming(): EventsResponse[] { - const today = todayISO() - return this.byDate.filter((r) => dayOf(r.starts) > today) - } - - /** Vorbei — der letzte Tag liegt hinter uns. */ - get past(): EventsResponse[] { - const today = todayISO() - return this.byDate - .filter((r) => { - const from = dayOf(r.starts) - if (!from) return false - return (dayOf(r.ends) || from) < today - }) - .reverse() - } - - /** Noch ohne Termin. */ - get undated(): EventsResponse[] { - return this.byDate.filter((r) => !dayOf(r.starts)) - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setEventContext() { - const store = new EventStore() - setContext(KEY, store) - return store -} - -export function getEventContext(): EventStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/pocketbase.svelte.ts b/frontend/src/lib/stores/pocketbase.svelte.ts deleted file mode 100644 index 5c12459..0000000 --- a/frontend/src/lib/stores/pocketbase.svelte.ts +++ /dev/null @@ -1,180 +0,0 @@ -import PocketBase, {type AuthRecord, type RecordModel} from 'pocketbase' -import {PUBLIC_PB_URL} from '$env/static/public' -import type {TypedPocketBase} from '$lib/types' - -export const api = new PocketBase(PUBLIC_PB_URL) as TypedPocketBase - -api.authStore.onChange(async () => { - auth.user = api.authStore.record -}) - -export const getFileURL = (record: object, file: string, options = {}) => { - // https://pocketbase.io/docs/files-handling/#file-url - return api.files.getURL(record, file, options) -} - -class AuthStore { - loading = $state(false) - username = $state('') - password = $state('') - cookie = $state(true) - error: any = $state('') - user = $state(null) - params = {} - - - load = async (cookie: string) => { - api.authStore.loadFromCookie(cookie) - } - - /** - * Anmelden. Ohne Argumente gelten die Felder des Stores, die das - * Login-Formular füllt; mit Argumenten die übergebenen — die - * Einladungsseite meldet damit das eben angelegte Konto an, ohne den - * Umweg über die Formularfelder. - */ - login = async (user = auth.username, password = auth.password) => { - - this.loading = true - this.error = '' - - try { - await api.collection('users').authWithPassword(user, password, this.params) - - this.user = api.authStore.record as AuthRecord | null - - if (auth.user) { - await api.collection('users').update( - auth.user.id, - { - last_login: new Date(Date.now()), - }, - this.params, - ) - } - - if (auth.cookie) { - document.cookie = api.authStore.exportToCookie({httpOnly: false}) - } - - } catch (e) { - this.error = e instanceof Error ? e.message : String(e) - } - - auth.username = auth.password = '' - - this.loading = false - } - - logout = async () => { - api.authStore.clear() - document.cookie = 'pb_auth="";-1; path=/' - - // Der Service Worker haelt gelesene API-Antworten vor, damit die App - // im Funkloch etwas anzuzeigen hat. Nach dem Abmelden hat sie das - // nicht mehr: Auf einem geteilten Geraet stuenden sonst die Daten des - // vorigen Nutzers offline weiter da. - navigator.serviceWorker?.controller?.postMessage('clear-api-cache') - } - - - refresh = async () => { - if (api.authStore.isValid) { - await api.collection('users').authRefresh(this.params) - } - } -} - -export const auth = new AuthStore() - - -class CollectionsStore { - - remove = async ({collection, store, record, params}: { - collection: string - store: any - record: Record - params?: Record - }) => { - await api.collection(collection).delete(record.id) - store.records = store.records.filter((i: any) => i.id !== record.id) - } - edit = async ({collection, store, record, params}: { - collection: string - store: any - record: FormData | Record - params?: Record - }) => { - - let id: string - let data: FormData | Record - - if (record instanceof FormData) { - id = record.get('id') as string - data = record - } else { - id = record.id - data = record - } - - // Fetch item via pocketbase API - let item = await api.collection(collection).update(id, data, params) - - // Find the index of the item - const index = store.records.findIndex((i: any) => i.id === id) - if (index !== -1) { - //.. to replace it at the same position - store.records.splice(index, 1, item) - } else { - // Fallback: add to beginning if not found (shouldn't happen in normal cases) - store.records = [ - item, - ...store.records.filter((i: any) => i.id !== id) - ] - } - - return item - } - - create = async ({collection, store, record, params}: { - collection: string - store: any - record: FormData | Record, - params?: Record - }) => { - let data: FormData | Record - - if (record instanceof FormData) { - data = record - } else { - data = record - } - - let item = await api.collection(collection).create(data, params) - store.records = [item, ...store.records] - return item - } - - refresh = async ({collection, store, id, params}: { - collection: string - store: any - id?: string - params?: Record - }) => { - if (id) { - // refreshes only a single record - let record: RecordModel = await api.collection(collection).getOne(id, params) - // and `crossfades` old to new record - store.records = [ - record, - ...store.records.filter((e: RecordModel) => e.id !== id) - ] - return record - } else { - store.records = await api.collection(collection).getFullList(params) - return store.records - } - } -} - -export const collections = new CollectionsStore() \ No newline at end of file diff --git a/frontend/src/lib/stores/riders.svelte.ts b/frontend/src/lib/stores/riders.svelte.ts deleted file mode 100644 index 714b22b..0000000 --- a/frontend/src/lib/stores/riders.svelte.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api } from './pocketbase.svelte' -import { app } from './app.svelte' -import { getTeamContext } from './teams.svelte' -import type { RidersResponse, UsersResponse } from '$lib/types' - -const KEY = Symbol('rider') - -/** - * Der Kader wird nach Namen sortiert. Nach Startnummern ginge es nicht mehr: - * Die gelten seit 1754501300 je Event und stehen am Teilnehmer. - */ -function sortByName(records: RidersResponse[]) { - return [...records].sort((a, b) => riderName(a).localeCompare(riderName(b))) -} - -/** - * Der Name eines Fahrers — ein Feld, ein Wert. - * - * Bis 1754501800_riders_single_name.js standen hier Vorname und Nachname - * getrennt und mussten überall wieder zusammengesetzt werden. Die Funktion - * bleibt trotzdem: Sie ist die eine Stelle, an der ein namenloser Fahrer zu - * einem leeren String wird, statt als `undefined` durch die Oberfläche zu - * geistern. - */ -export function riderName(r: RidersResponse) { - return (r.name ?? '').trim() -} - -export class RiderStore { - records = $state([]) - loading = $state(false) - error = $state(null) - searchQuery = $state('') - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - - get scoped(): RidersResponse[] { - const teamId = this.teams.activeId - if (!teamId) return [] - return this.records.filter((r) => r.team === teamId) - } - - /** - * Anlegen, ändern und löschen darf nur die Teamleitung — dieselbe Regel - * wie in createRule/updateRule/deleteRule der Collection. Die Prüfung - * hier blendet nur UI aus; durchgesetzt wird sie serverseitig. - */ - get canManage(): boolean { - return this.teams.isAdmin() - } - - async load() { - this.loading = true - this.error = null - try { - const raw = await api.collection('riders').getFullList({ requestKey: null }) - this.records = sortByName(raw) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Fahrer' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('riders').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) { - this.records = sortByName([...this.records, e.record]) - } else if (e.action === 'update' && idx !== -1) { - this.records[idx] = e.record - this.records = sortByName(this.records) - } else if (e.action === 'delete' && idx !== -1) { - this.records = this.records.filter((r) => r.id !== e.record.id) - } - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('riders subscribe failed:', e)) - } - - async create(data: Partial) { - const teamId = this.teams.activeId - if (!teamId) throw new Error('Kein aktives Team') - return await api.collection('riders').create({ ...data, team: teamId }) - } - - async edit(id: string, data: Partial) { - return await api.collection('riders').update(id, data) - } - - /** - * Fahrer löschen — und mit ihm die Mitgliedschaft seines Kontos. - * - * Ohne den zweiten Schritt riss jedes Löschen ein Loch: Der Fahrer war - * fort, sein Konto blieb in `teams.users` und stand danach als Zeile ohne - * Fahrer im Kader. Aus so einer Zeile führte kein Weg zurück — ein Konto - * nachträglich an einen Fahrer zu hängen gibt es bewusst nicht, weil - * dieselbe Person sonst zweimal in der Liste stehen könnte. Der Eintrag - * war damit unreparierbar. - * - * Das Konto selbst bleibt bestehen: Löschen darf es allein sein Inhaber - * (`users.deleteRule = id = @request.auth.id`), und über einen neuen - * Einladungslink kommt es zurück ins Team. - * - * Der Owner ist ausgenommen. Ihn aus `teams.users` zu nehmen, nähme ihm - * die Sicht auf sein eigenes Team — die listRule der meisten Collections - * hängt an `team.users`, nicht an `team.owner`. - */ - remove(record: RidersResponse) { - const team = this.teams.records.find((t) => t.id === record.team) - const userId = record.user || null - const dropsMembership = !!userId && !!team && userId !== team.owner - - const text = dropsMembership - ? `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht. ` + - `Das zugehörige Konto verliert seine Mitgliedschaft im Team — gelöscht wird es nicht, es kann über einen Einladungslink zurückkommen.` - : `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.` - - return new Promise((resolve, reject) => { - app.confirm.request({ - title: 'Fahrer löschen?', - text, - yes: async () => { - try { - // Erst die Mitgliedschaft, dann der Fahrer. Andersherum - // stünde bei einem Fehler im zweiten Schritt genau das - // verwaiste Konto da, das hier verhindert werden soll. - if (dropsMembership) { - await this.teams.removeMember(team!.id, userId!) - } - - await api.collection('riders').delete(record.id) - app.confirm.close() - resolve() - } catch (e) { - app.confirm.close() - reject(e) - } - }, - no: () => { - app.confirm.close() - resolve() - }, - }) - }) - } - - getById(id: string) { - return this.records.find((r) => r.id === id) - } - - /** Fahrer, der zu einem Login gehört — für „das bin ich"-Ansichten. */ - getByUser(userId: string) { - return this.scoped.filter((r) => r.user === userId) - } - - /** - * Konto an den Fahrer hängen. Nur intern und nur in diese Richtung: Ein - * Konto entsteht am Fahrer, ein Lösen der Verbindung gibt es nicht — sonst - * stünde dieselbe Person zweimal in der Liste, einmal mit und einmal ohne - * Login. - */ - private async linkUser(riderId: string, userId: string) { - return await this.edit(riderId, { user: userId } as Partial) - } - - /** - * Legt dem Fahrer ein Login an und verknüpft es. Das Konto entsteht im - * Team-Store — es ist zuerst ein Teammitglied und erst dann dieser Fahrer. - */ - async createLogin( - rider: RidersResponse, - data: { email: string; password: string; name?: string }, - ): Promise { - const user = await this.teams.createMember({ - ...data, - name: data.name?.trim() || riderName(rider), - }) - - await this.linkUser(rider.id, user.id) - - return user - } - - get filtered() { - const q = this.searchQuery.toLowerCase().trim() - if (!q) return this.scoped - return this.scoped.filter((r) => riderName(r).toLowerCase().includes(q)) - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setRiderContext() { - const store = new RiderStore() - setContext(KEY, store) - return store -} - -export function getRiderContext(): RiderStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/stages.svelte.ts b/frontend/src/lib/stores/stages.svelte.ts deleted file mode 100644 index ed3a8a6..0000000 --- a/frontend/src/lib/stores/stages.svelte.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api } from './pocketbase.svelte' -import { app } from './app.svelte' -import { getTeamContext } from './teams.svelte' -import type { StagesResponse } from '$lib/types' - -const KEY = Symbol('stage') - -export class StageStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - - get scoped(): StagesResponse[] { - const teamId = this.teams.activeId - if (!teamId) return [] - return this.records.filter((r) => r.team === teamId) - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('stages').getFullList({ - sort: '+created', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Stages' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('stages').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('stages subscribe failed:', e)) - } - - async create(data: Partial) { - const teamId = this.teams.activeId - if (!teamId) throw new Error('Kein aktives Team') - return await api.collection('stages').create({ ...data, team: teamId }) - } - - async edit(id: string, data: Partial) { - return await api.collection('stages').update(id, data) - } - - remove(record: StagesResponse) { - return new Promise((resolve, reject) => { - app.confirm.request({ - title: 'Stage löschen?', - text: `Möchten Sie „${record.description}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`, - yes: async () => { - try { - await api.collection('stages').delete(record.id) - app.confirm.close() - resolve() - } catch (e) { - app.confirm.close() - reject(e) - } - }, - no: () => { - app.confirm.close() - resolve() - }, - }) - }) - } - - getById(id: string) { - return this.records.find((r) => r.id === id) - } - - getByEvent(eventId: string) { - return this.scoped.filter((r) => r.event === eventId) - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setStageContext() { - const store = new StageStore() - setContext(KEY, store) - return store -} - -export function getStageContext(): StageStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/teamInvites.svelte.ts b/frontend/src/lib/stores/teamInvites.svelte.ts deleted file mode 100644 index 7009060..0000000 --- a/frontend/src/lib/stores/teamInvites.svelte.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api, auth } from './pocketbase.svelte' -import { getTeamContext } from './teams.svelte' -import type { TeamInvitesResponse } from '$lib/types' - -const KEY = Symbol('teamInvite') - -/** - * Einladungslinks eines Teams. - * - * Zwei Seiten, zwei Wege: Die Teamleitung legt Links über die Collection an - * und sieht sie dort — das regeln die Regeln in - * 1754501600_team_invites.js. Wer einen Link einlöst, ist noch nicht - * eingeloggt und läuft über die öffentlichen Routen aus - * backend/pb_hooks/team_invites.pb.js; die stehen unten als `invites.public`. - */ -export class TeamInviteStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - - get scoped(): TeamInvitesResponse[] { - const teamId = this.teams.activeId - if (!teamId) return [] - return this.records.filter((r) => r.team === teamId) - } - - /** - * Einladungen verwaltet nur die Teamleitung — dieselbe Regel wie in - * list/create/deleteRule der Collection. Die Prüfung hier blendet nur UI - * aus; durchgesetzt wird sie serverseitig. - */ - get canManage(): boolean { - return this.teams.isAdmin() - } - - /** Gilt der Link noch? Abgelaufen und aufgebraucht sehen gleich aus. */ - isValid(invite: TeamInvitesResponse): boolean { - if (invite.expires && new Date(invite.expires).getTime() < Date.now()) return false - const max = invite.max_uses ?? 0 - return !(max > 0 && (invite.uses ?? 0) >= max) - } - - /** Der Link zum Weitergeben. Ohne Fenster (SSR) gibt es keinen Host. */ - url(invite: TeamInvitesResponse): string { - const origin = typeof location !== 'undefined' ? location.origin : '' - return `${origin}/invite/${invite.token}` - } - - async load() { - // Ein einfaches Mitglied bekäme hier ohnehin eine leere Liste; der - // Aufruf entfällt trotzdem, damit das Dashboard beim Start eine - // Anfrage weniger stellt. - if (!auth.user) return - - this.loading = true - this.error = null - try { - this.records = await api.collection('team_invites').getFullList({ - sort: '-created', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Einladungen' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('team_invites').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) { - this.records = this.records.filter((r) => r.id !== e.record.id) - } - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('team_invites subscribe failed:', e)) - } - - /** - * Neuen Link erzeugen. Den Token setzt PocketBase selbst — ihn - * mitzuschicken lehnt die createRule ab, damit sich niemand einen leicht - * zu ratenden aussuchen kann. - * - * `days` 0 heißt: läuft nicht ab, `maxUses` 0: beliebig oft einlösbar. - */ - async create({ days = 0, maxUses = 0 } = {}, teamId = this.teams.activeId) { - if (!teamId) throw new Error('Kein aktives Team') - - const data: Record = { - team: teamId, - max_uses: maxUses, - uses: 0, - created_by: auth.user?.id ?? '', - } - - if (days > 0) { - const until = new Date() - until.setDate(until.getDate() + days) - data.expires = until.toISOString() - } - - const invite = await api.collection('team_invites').create(data) - this.records = [invite, ...this.records] - return invite as TeamInvitesResponse - } - - /** - * Link zurückziehen. Ohne Rückfrage: Ein Einladungslink ist nichts, was - * verloren gehen kann — wer einen braucht, erzeugt den nächsten. - */ - async remove(id: string) { - await api.collection('team_invites').delete(id) - this.records = this.records.filter((r) => r.id !== id) - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -/** - * Die öffentliche Seite einer Einladung — ohne Login, ohne Store, ohne Team. - * Sie läuft gegen die Routen aus backend/pb_hooks/team_invites.pb.js, nicht - * gegen eine Collection: Ein Nicht-Eingeloggter darf `team_invites` nicht - * einmal sehen. - */ -export type InviteTeam = { - id: string - collectionId: string - collectionName: string - name: string - logo: string -} - -async function send(path: string, body?: unknown) { - const res = await fetch(`${api.baseURL.replace(/\/$/, '')}${path}`, { - method: body === undefined ? 'GET' : 'POST', - headers: { - 'Content-Type': 'application/json', - // Für /join muss der Server wissen, wer beitritt. - ...(api.authStore.token ? { Authorization: api.authStore.token } : {}), - }, - body: body === undefined ? undefined : JSON.stringify(body), - }) - - const data = await res.json().catch(() => ({})) - if (!res.ok) throw new Error(data?.message || 'Die Einladung konnte nicht geöffnet werden.') - - return data -} - -export const publicInvite = { - /** Zu welchem Team gehört der Link? Wirft, wenn er nicht (mehr) gilt. */ - async load(token: string): Promise { - const data = await send(`/api/team-invites/${encodeURIComponent(token)}`) - return data.team as InviteTeam - }, - - /** Konto anlegen und beitreten. */ - async accept( - token: string, - data: { email: string; password: string; passwordConfirm: string; name: string }, - ) { - return await send(`/api/team-invites/${encodeURIComponent(token)}/accept`, data) - }, - - /** Als bereits angemeldeter Nutzer beitreten. */ - async join(token: string, data: { name?: string } = {}) { - return await send(`/api/team-invites/${encodeURIComponent(token)}/join`, data) - }, -} - -export function setTeamInviteContext() { - const store = new TeamInviteStore() - setContext(KEY, store) - return store -} - -export function getTeamInviteContext(): TeamInviteStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/teams.svelte.ts b/frontend/src/lib/stores/teams.svelte.ts deleted file mode 100644 index 4807784..0000000 --- a/frontend/src/lib/stores/teams.svelte.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api, auth } from './pocketbase.svelte' -import { app } from './app.svelte' -import type { TeamsResponse, UsersResponse } from '$lib/types' - -const KEY = Symbol('team') -const LS_KEY = 'activeTeamId' - -export class TeamStore { - records = $state([]) - activeId = $state(null) - loading = $state(false) - error = $state(null) - - /** - * Aufgelöste User-Datensätze, nach ID. Ein Team kennt nur die IDs seiner - * Mitglieder; die Namen holt sich die Oberfläche einzeln nach. Cache, - * damit dieselbe ID nicht auf jeder Seite erneut geladen wird. - */ - users = $state>({}) - - private unsubscribe: (() => void) | null = null - - get active(): TeamsResponse | null { - return this.records.find((t) => t.id === this.activeId) ?? null - } - - /** ID des aktuell eingeloggten Users */ - private get me(): string | null { - return api.authStore.record?.id ?? null - } - - isOwner(team: TeamsResponse | null = this.active): boolean { - if (!team || !this.me) return false - return team.owner === this.me - } - - isAdmin(team: TeamsResponse | null = this.active): boolean { - if (!team || !this.me) return false - if (this.isOwner(team)) return true - return (team.admins ?? []).includes(this.me) - } - - isMember(team: TeamsResponse | null = this.active): boolean { - if (!team || !this.me) return false - return (team.users ?? []).includes(this.me) - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('teams').getFullList({ - sort: '+name', - requestKey: null, - }) - - // Aktives Team aus localStorage wiederherstellen, sonst erstes nehmen - const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(LS_KEY) : null - if (stored && this.records.some((t) => t.id === stored)) { - this.activeId = stored - } else if (this.records.length > 0) { - this.activeId = this.records[0].id - } else { - this.activeId = null - } - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Teams' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('teams').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record].sort((a, b) => a.name.localeCompare(b.name)) - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) { - this.records = this.records.filter((r) => r.id !== e.record.id) - if (this.activeId === e.record.id) { - this.activeId = this.records[0]?.id ?? null - } - } - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('teams subscribe failed:', e)) - } - - setActive(id: string) { - if (!this.records.some((t) => t.id === id)) return - this.activeId = id - if (typeof localStorage !== 'undefined') localStorage.setItem(LS_KEY, id) - } - - async create(name: string) { - if (!this.me) throw new Error('Nicht eingeloggt') - const team = await api.collection('teams').create({ - name, - owner: this.me, - users: [this.me], - admins: [], - }) - // sofort als aktiv setzen - setTimeout(() => this.setActive(team.id), 0) - return team - } - - async rename(id: string, name: string) { - return await api.collection('teams').update(id, { name }) - } - - /** - * Logo setzen oder entfernen. Mit Datei als FormData — ein leeres Feld im - * FormData würde ein vorhandenes Bild löschen, deshalb der zweite Weg. - */ - async setLogo(id: string, file: File | null) { - if (!file) { - return await api.collection('teams').update(id, { logo: null }) - } - - const data = new FormData() - data.append('logo', file) - return await api.collection('teams').update(id, data) - } - - /** Mitglied hinzufügen (Owner/Admin only) */ - async addMember(teamId: string, userId: string) { - const team = this.records.find((t) => t.id === teamId) - if (!team) throw new Error('Team nicht gefunden') - const users = [...(team.users ?? [])] - if (!users.includes(userId)) users.push(userId) - return await api.collection('teams').update(teamId, { users }) - } - - /** Mitglied entfernen */ - async removeMember(teamId: string, userId: string) { - const team = this.records.find((t) => t.id === teamId) - if (!team) throw new Error('Team nicht gefunden') - const users = (team.users ?? []).filter((u) => u !== userId) - const admins = (team.admins ?? []).filter((u) => u !== userId) - return await api.collection('teams').update(teamId, { users, admins }) - } - - async promoteToAdmin(teamId: string, userId: string) { - const team = this.records.find((t) => t.id === teamId) - if (!team) throw new Error('Team nicht gefunden') - const admins = [...(team.admins ?? [])] - if (!admins.includes(userId)) admins.push(userId) - return await api.collection('teams').update(teamId, { admins }) - } - - async demoteFromAdmin(teamId: string, userId: string) { - const team = this.records.find((t) => t.id === teamId) - if (!team) throw new Error('Team nicht gefunden') - const admins = (team.admins ?? []).filter((u) => u !== userId) - return await api.collection('teams').update(teamId, { admins }) - } - - /** Verlassen — entfernt sich selbst aus users + admins */ - async leave(teamId: string) { - if (!this.me) return - return await this.removeMember(teamId, this.me) - } - - /** Team löschen (nur Owner) */ - remove(team: TeamsResponse) { - return new Promise((resolve, reject) => { - app.confirm.request({ - title: 'Team löschen?', - text: `Möchtest du das Team „${team.name}" wirklich löschen? Achtung: zugehörige Events, Fahrer und Zeiten werden NICHT gelöscht, sind aber für niemanden mehr sichtbar.`, - yes: async () => { - try { - await api.collection('teams').delete(team.id) - app.confirm.close() - resolve() - } catch (e) { - app.confirm.close() - reject(e) - } - }, - no: () => { - app.confirm.close() - resolve() - }, - }) - }) - } - - /** - * Einen User nachladen. Sichtbar ist ein fremder Datensatz nur über seine - * ID (users.viewRule); durchblättern lässt sich die Collection nicht. - */ - async loadUser(id: string): Promise { - if (this.users[id]) return this.users[id] - - try { - const u = await api.collection('users').getOne(id, { requestKey: null }) - this.users = { ...this.users, [id]: u } - return u - } catch { - return null - } - } - - /** Alle Mitglieder eines Teams in den Cache holen. */ - async loadMembers(team: TeamsResponse | null = this.active) { - if (!team) return - const ids = [...(team.users ?? []), team.owner].filter(Boolean) as string[] - await Promise.all(ids.filter((id) => !this.users[id]).map((id) => this.loadUser(id))) - } - - /** Anzeigename zu einer User-ID; fällt auf eine gekürzte ID zurück. */ - userLabel(id: string): string { - const u = this.users[id] - if (!u) return id.slice(0, 6) + '…' - return u.name || u.email || id.slice(0, 6) + '…' - } - - /** - * Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die - * REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das - * Konto bereits — der Aufrufer meldet den Fehler, statt still - * weiterzumachen. - * - * Erlaubt ist das seit 1754500500_riders_user_and_admin_rules.js jedem - * eingeloggten Nutzer (users.createRule); eine öffentliche Registrierung - * gibt es weiterhin nicht. - */ - async createMember(data: { email: string; password: string; name?: string }, teamId = this.activeId) { - if (!teamId) throw new Error('Kein aktives Team') - - const user = (await api.collection('users').create({ - email: data.email.trim(), - emailVisibility: false, - password: data.password, - passwordConfirm: data.password, - name: data.name?.trim() || data.email.trim(), - })) as UsersResponse - - this.users = { ...this.users, [user.id]: user } - await this.addMember(teamId, user.id) - - return user - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setTeamContext() { - const store = new TeamStore() - setContext(KEY, store) - return store -} - -export function getTeamContext(): TeamStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/ticker.svelte.ts b/frontend/src/lib/stores/ticker.svelte.ts deleted file mode 100644 index 84e0742..0000000 --- a/frontend/src/lib/stores/ticker.svelte.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Laufende Uhr für Live-Anzeigen. Wird im Komponenten-Setup aufgerufen; das - * Intervall hängt am Effekt-Kontext der Komponente und endet mit ihr. - * - * Der Wert wird bewusst als Zahl herausgereicht statt als fertiger Text: - * Ableitungen, die `now` lesen, rechnen dadurch bei jedem Tick neu — ohne - * `{#key}`-Blöcke, die sonst ganze Listen neu aufbauen würden. - */ -export function ticker(intervalMs = 100) { - let now = $state(Date.now()) - - $effect(() => { - const id = setInterval(() => (now = Date.now()), intervalMs) - return () => clearInterval(id) - }) - - return { - get now() { - return now - }, - } -} diff --git a/frontend/src/lib/stores/timeOutbox.svelte.ts b/frontend/src/lib/stores/timeOutbox.svelte.ts deleted file mode 100644 index bb9c96a..0000000 --- a/frontend/src/lib/stores/timeOutbox.svelte.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { api } from './pocketbase.svelte' -import type { TimesStatusOptions } from '$lib/types' - -const KEY = 'timeOutbox' - -/** - * Ein Start oder ein Stopp, der noch nicht beim Server ist. - * - * `at` ist die abgeglichene Zeit im Moment des Drückens — nicht die des - * Übertragens. Darin liegt der ganze Sinn: Wann die Nachricht rausgeht, darf - * die gemessene Zeit nicht berühren. - * - * Ein Stopp trägt kein `time`, wenn die Zeit selbst noch im Ausgang liegt - * (offline gestartet und offline gestoppt). Er zeigt dann über `localId` auf - * den Start und wird erst beim Übertragen aufgelöst. - */ -export type OutboxEntry = - | { - kind: 'start' - localId: string - at: string - stage: string - rider: string - team: string - startedBy?: string - } - | { - kind: 'stop' - localId: string - at: string - status: TimesStatusOptions - /** ID der Zeit auf dem Server — fehlt, wenn der Start noch aussteht. */ - time?: string - /** Start aus diesem Ausgang, auf den sich der Stopp bezieht. */ - startLocalId?: string - stoppedBy?: string - } - -/** - * Was an der Strecke gedrückt wurde und noch nicht beim Server ankam. - * - * Eine Stage lässt sich nicht wiederholen. Ein Funkloch im falschen Moment - * darf deshalb nicht bedeuten, dass eine Zeit verloren ist — gedrückt wird - * ins localStorage, übertragen wird, sobald es geht. Die Reihenfolge bleibt - * erhalten: Ein Stopp kann auf einen Start warten, der selbst noch aussteht. - * - * Bewusst kein Kontext-Store: Der Ausgang gehört dem Gerät und überlebt - * Seitenwechsel, Neustart und Abmeldung. - */ -export class TimeOutbox { - entries = $state([]) - /** Läuft gerade eine Übertragung? */ - flushing = $state(false) - /** Was beim letzten Versuch schiefging — bleibt stehen, bis es klappt. */ - error = $state(null) - - private online = $state(true) - - constructor() { - if (typeof localStorage !== 'undefined') { - try { - const raw = localStorage.getItem(KEY) - if (raw) this.entries = JSON.parse(raw) as OutboxEntry[] - } catch { - // Kaputter Ausgang: lieber leer weiter als beim Start scheitern. - } - } - - if (typeof window !== 'undefined') { - this.online = navigator.onLine - window.addEventListener('online', () => { - this.online = true - void this.flush() - }) - window.addEventListener('offline', () => (this.online = false)) - } - } - - get pending(): number { - return this.entries.length - } - - /** - * Liegt für diese Zeit schon ein Stopp im Ausgang? - * - * Ohne diese Frage liesse sich dieselbe Zeit zweimal stoppen, solange das - * Netz weg ist — und beim Übertragen gewänne der zweite Eintrag, also die - * falsche Zeit. Ein Stopp ist endgültig, auch wenn er noch hier liegt. - */ - hasPendingStop(timeId: string): boolean { - return this.entries.some( - (e) => - e.kind === 'stop' && - (e.time === timeId || (e.startLocalId && `local:${e.startLocalId}` === timeId)), - ) - } - - /** - * Was tun, wenn der Ausgang leer ist. Der Zeit-Store haengt sich hier ein, - * um seine vorläufigen Datensätze gegen die echten vom Server zu tauschen. - */ - onDrained(fn: () => void) { - this.drained = fn - } - - private drained: (() => void) | null = null - - get isOnline(): boolean { - return this.online - } - - private persist() { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(KEY, JSON.stringify(this.entries)) - } catch { - // Ohne Speicher bleibt der Ausgang in dieser Sitzung — dann muss - // die Übertragung klappen, bevor jemand die Seite schliesst. - } - } - - private add(entry: OutboxEntry) { - this.entries = [...this.entries, entry] - this.persist() - } - - /** Start vormerken. Gibt die lokale ID zurück, damit ein Stopp sie findet. */ - queueStart(entry: Omit, 'kind' | 'localId'>): string { - const localId = crypto.randomUUID() - this.add({ kind: 'start', localId, ...entry }) - return localId - } - - queueStop(entry: Omit, 'kind' | 'localId'>): string { - const localId = crypto.randomUUID() - this.add({ kind: 'stop', localId, ...entry }) - return localId - } - - /** - * Alles übertragen, was geht — der Reihe nach, denn ein Stopp kann auf - * seinen Start warten. Der erste Fehler bricht ab: Ist das Netz weg, ist - * es auch für den nächsten Eintrag weg, und die Reihenfolge muss halten. - */ - async flush(): Promise { - if (this.flushing || this.entries.length === 0) return - - this.flushing = true - this.error = null - - // Zeit-IDs, die in diesem Durchlauf entstanden sind — ein Stopp aus - // dem Ausgang kennt nur die lokale ID seines Starts. - const resolved = new Map() - - try { - while (this.entries.length > 0) { - const entry = this.entries[0] - - if (entry.kind === 'start') { - const record = await api.collection('times').create({ - stage: entry.stage, - rider: entry.rider, - team: entry.team, - start: entry.at, - status: 'active', - startedBy: entry.startedBy, - correction: 0, - }) - resolved.set(entry.localId, record.id) - } else { - const timeId = - entry.time ?? - (entry.startLocalId ? resolved.get(entry.startLocalId) : undefined) - - if (!timeId) { - // Der zugehörige Start ist weder auf dem Server noch in - // diesem Durchlauf entstanden. Weiterreichen wäre ein - // stiller Verlust, also stehen lassen und melden. - throw new Error( - 'Zu einem gestoppten Lauf fehlt der Start. Die Zeit bleibt im Ausgang.', - ) - } - - await api.collection('times').update(timeId, { - stop: entry.at, - status: entry.status, - stoppedBy: entry.stoppedBy, - }) - } - - this.entries = this.entries.slice(1) - this.persist() - } - - this.drained?.() - } catch (e: any) { - this.error = e?.message ?? 'Die Übertragung ist fehlgeschlagen.' - } finally { - this.flushing = false - } - } -} - -export const timeOutbox = new TimeOutbox() diff --git a/frontend/src/lib/stores/times.svelte.ts b/frontend/src/lib/stores/times.svelte.ts deleted file mode 100644 index 6c445c4..0000000 --- a/frontend/src/lib/stores/times.svelte.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api } from './pocketbase.svelte' -import { app } from './app.svelte' -import { getTeamContext } from './teams.svelte' -import { clockSync } from './clockSync.svelte' -import { timeOutbox } from './timeOutbox.svelte' -import { formatDuration } from '$lib/time' -import type { TimesResponse, TimesStatusOptions } from '$lib/types' - -const KEY = Symbol('time') - -/** - * Vorläufige Datensätze, die nur dieses Gerät kennt. Das Präfix macht sie - * überall erkennbar — eine PocketBase-ID hat 15 Zeichen aus [a-z0-9] und - * kann keinen Doppelpunkt enthalten. - */ -const LOCAL_PREFIX = 'local:' - -export type TimeStatus = TimesStatusOptions - -export interface TimeWithDuration extends TimesResponse { - duration?: number - formattedTime?: string -} - -/** - * `now` steckt als Parameter drin, damit laufende Zeiten live weiterzählen: - * Eine Ableitung, die einen tickenden Zeitwert hereinreicht, rechnet bei - * jedem Tick neu. Ohne den Parameter wäre `Date.now()` für Svelte keine - * Abhängigkeit und die Anzeige stünde still. - */ -function enrich(t: TimesResponse, now: number): TimeWithDuration { - if (!t.start) return t - const start = new Date(t.start).getTime() - const end = t.stop ? new Date(t.stop).getTime() : now - let duration = (end - start) / 1000 - if (t.correction) duration += t.correction - return { - ...t, - duration, - formattedTime: formatDuration(duration), - } -} - -/** - * War das ein Funkloch oder eine Absage? - * - * Nur beim Funkloch darf der Ausgang auffangen. Eine abgelehnte Anfrage — - * fehlende Rechte, ungültige Daten — gehoert dem Aufrufer gemeldet, sonst - * sammelte der Ausgang stumm Eintraege, die auch beim naechsten Versuch - * scheitern. PocketBase setzt bei Netzfehlern `status` auf 0. - */ -function isOffline(e: unknown): boolean { - if (typeof navigator !== 'undefined' && !navigator.onLine) return true - - const status = (e as { status?: number } | null)?.status - return status === 0 || status === undefined -} - -export class TimeStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - - constructor() { - // Ist der Ausgang leer, sind alle vorläufigen Zeiten beim Server - // angekommen. Einmal neu laden tauscht sie gegen die echten — mit - // deren IDs, ohne die sich später nichts mehr korrigieren liesse. - timeOutbox.onDrained(() => void this.load()) - } - - get scoped(): TimesResponse[] { - const teamId = this.teams.activeId - if (!teamId) return [] - return this.records.filter((r) => r.team === teamId) - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('times').getFullList({ - sort: '-created', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Zeiten' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('times').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('times subscribe failed:', e)) - } - - /** - * Zeit starten. - * - * Der Zeitstempel kommt aus dem Uhrenabgleich, nicht aus `new Date()`: - * Start und Stopp geschehen oft auf verschiedenen Geräten, und deren - * Uhrenversatz steckte sonst ungeprüft in jeder gemessenen Dauer. - * - * Scheitert die Übertragung, wandert der Start in den Ausgang statt in - * einen Fehler. Eine Stage lässt sich nicht wiederholen — im Funkloch - * gedrückt bleibt gedrückt, übertragen wird später. - */ - async start(stageId: string, riderId: string) { - const teamId = this.teams.activeId - if (!teamId) throw new Error('Kein aktives Team') - - const at = clockSync.iso() - const startedBy = api.authStore.record?.id - - try { - return await api.collection('times').create({ - stage: stageId, - rider: riderId, - team: teamId, - start: at, - status: 'active' as TimesStatusOptions, - startedBy, - correction: 0, - }) - } catch (e) { - if (!isOffline(e)) throw e - - // Der Start wandert in den Ausgang — und zugleich als vorläufiger - // Datensatz in die Liste. Ohne ihn stünde die laufende Zeit - // nirgends, und niemand könnte sie stoppen: Die Oberfläche kennt - // nur, was hier steht. - const localId = timeOutbox.queueStart({ - at, - stage: stageId, - rider: riderId, - team: teamId, - startedBy, - }) - - const draft = { - id: `${LOCAL_PREFIX}${localId}`, - stage: stageId, - rider: riderId, - team: teamId, - start: at, - status: 'active', - startedBy, - correction: 0, - } as unknown as TimesResponse - - this.records = [...this.records, draft] - return null - } - } - - /** Gehört diese ID zu einem Datensatz, den nur dieses Gerät kennt? */ - isLocal(timeId: string): boolean { - return timeId.startsWith(LOCAL_PREFIX) - } - - /** - * Zeit stoppen. Wie beim Start gilt die abgeglichene Zeit des Drückens, - * und wie beim Start faengt der Ausgang auf, was das Netz nicht nimmt. - */ - async stop(timeId: string, status: TimeStatus = 'finished' as TimesStatusOptions) { - const at = clockSync.iso() - const stoppedBy = api.authStore.record?.id - - // Ein Stopp ist endgültig, auch wenn er noch im Ausgang liegt. Ohne - // diese Sperre liesse sich dieselbe Zeit im Funkloch zweimal stoppen, - // und beim Übertragen gewänne der zweite Druck — die falsche Zeit. - if (timeOutbox.hasPendingStop(timeId)) { - throw new Error('Diese Zeit ist bereits gestoppt und wartet auf die Übertragung.') - } - - // Ein Lauf, den nur dieses Gerät kennt, kann gar nicht am Server - // gestoppt werden — sein Start ist dort noch nicht angekommen. - if (this.isLocal(timeId)) { - timeOutbox.queueStop({ - at, - status, - startLocalId: timeId.slice(LOCAL_PREFIX.length), - stoppedBy, - }) - this.markStopped(timeId, at, status, stoppedBy) - return null - } - - try { - return await api.collection('times').update(timeId, { - stop: at, - status, - stoppedBy, - }) - } catch (e) { - if (!isOffline(e)) throw e - - timeOutbox.queueStop({ at, status, time: timeId, stoppedBy }) - // Sofort auch hier eintragen, sonst stünde die Zeit weiter als - // laufend da und liesse sich ein zweites Mal stoppen. - this.markStopped(timeId, at, status, stoppedBy) - return null - } - } - - /** - * Eine Zeit vorläufig als gestoppt führen, solange der Server nichts davon - * weiss. Beim Übertragen kommt der echte Datensatz nach und ersetzt sie. - */ - private markStopped(timeId: string, at: string, status: TimeStatus, stoppedBy?: string) { - const idx = this.records.findIndex((t) => t.id === timeId) - if (idx === -1) return - - const next = [...this.records] - next[idx] = { ...next[idx], stop: at, status, stoppedBy } as TimesResponse - this.records = next - } - - /** - * Zeitkorrektur setzen, mit optionalem Grund. - * - * Der Grund steht im vorhandenen Feld `comment`. Eine Strafe ohne Grund - * ist nach dem Event nicht mehr zu verteidigen — Pflicht ist er trotzdem - * nicht: An der Strecke zaehlt, dass die Zahl stimmt. - */ - async applyCorrection(timeId: string, seconds: number, comment?: string) { - return await api.collection('times').update(timeId, { - correction: seconds, - comment: comment?.trim() ?? '', - }) - } - - async updateStatus(timeId: string, status: TimeStatus) { - return await api.collection('times').update(timeId, { status }) - } - - remove(record: TimesResponse) { - return new Promise((resolve, reject) => { - app.confirm.request({ - title: 'Zeit löschen?', - text: 'Möchten Sie diese Zeit wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.', - yes: async () => { - try { - await api.collection('times').delete(record.id) - app.confirm.close() - resolve() - } catch (e) { - app.confirm.close() - reject(e) - } - }, - no: () => { - app.confirm.close() - resolve() - }, - }) - }) - } - - getByStage(stageId: string, now = Date.now()): TimeWithDuration[] { - return this.scoped.filter((r) => r.stage === stageId).map((r) => enrich(r, now)) - } - - getByRider(riderId: string, now = Date.now()): TimeWithDuration[] { - return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now)) - } - - /** Laufende Zeiten einer Stage — die am längsten laufende zuerst. */ - runningByStage(stageId: string, now = Date.now()): TimeWithDuration[] { - return this.getByStage(stageId, now) - .filter((r) => r.status === 'active') - .sort((a, b) => (a.start < b.start ? -1 : 1)) - } - - /** - * Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf, - * dürfen aber auch nicht unsichtbar werden. - */ - unrankedByStage(stageId: string): TimeWithDuration[] { - return this.getByStage(stageId).filter( - (r) => r.status !== 'active' && r.status !== 'finished', - ) - } - - isRiderActive(stageId: string, riderId: string) { - return this.scoped.some( - (r) => r.stage === stageId && r.rider === riderId && r.status === 'active', - ) - } - - getActiveTime(stageId: string, riderId: string) { - return this.scoped.find( - (r) => r.stage === stageId && r.rider === riderId && r.status === 'active', - ) - } - - get running(): TimeWithDuration[] { - return this.allRunning() - } - - /** - * Alle laufenden Zeiten des Teams, stage-übergreifend — der am längsten - * laufende zuerst. `now` wie bei `getByStage`, damit die Anzeige mitzählt. - */ - allRunning(now = Date.now()): TimeWithDuration[] { - return this.scoped - .filter((r) => r.status === 'active') - .map((r) => enrich(r, now)) - .sort((a, b) => (a.start < b.start ? -1 : 1)) - } - - get finished(): TimeWithDuration[] { - const now = Date.now() - return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now)) - } - - getLeaderboard(stageId: string): TimeWithDuration[] { - return this.getByStage(stageId) - .filter((r) => r.status === 'finished' && r.duration !== undefined) - .sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0)) - } - - /** Platz eines Fahrers im Stage, 1-basiert; null ohne gewertete Zeit. */ - placement(stageId: string, riderId: string): number | null { - const idx = this.getLeaderboard(stageId).findIndex((t) => t.rider === riderId) - return idx === -1 ? null : idx + 1 - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setTimeContext() { - const store = new TimeStore() - setContext(KEY, store) - return store -} - -export function getTimeContext(): TimeStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/trailComments.svelte.ts b/frontend/src/lib/stores/trailComments.svelte.ts deleted file mode 100644 index 1907154..0000000 --- a/frontend/src/lib/stores/trailComments.svelte.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api, auth } from './pocketbase.svelte' -import { getTeamContext } from './teams.svelte' -import { isRecent } from '$lib/time' -import type { TrailCommentsResponse, UsersResponse } from '$lib/types' - -const KEY = Symbol('trailComment') - -/** Kommentar samt Verfasser, sofern dessen Datensatz sichtbar ist. */ -export type TrailComment = Omit & { - expand?: { created_by?: UsersResponse } -} - -/** - * Kommentare lagen bisher direkt in der Trail-Seite — als es sie nur dort - * gab, war das die einfachere Lösung. Die Trail-Liste zeigt jetzt ebenfalls - * ihre Anzahl, und zwei Ladewege auf dieselben Daten wären einer zu viel. - */ -export class TrailCommentStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - - get scoped(): TrailComment[] { - const teamId = this.teams.activeId - if (!teamId) return [] - return this.records.filter((r) => r.team === teamId) - } - - /** Kommentare eines Trails, jüngste zuerst */ - byTrail(trailId: string): TrailComment[] { - return this.scoped - .filter((r) => r.trail === trailId) - .sort((a, b) => (a.created < b.created ? 1 : -1)) - } - - /** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */ - hasRecent(trailId: string, days?: number): boolean { - return this.byTrail(trailId).some((r) => isRecent(r.created, days)) - } - - async load() { - this.loading = true - this.error = null - try { - this.records = (await api.collection('trail_comments').getFullList({ - sort: '-created', - expand: 'created_by', - requestKey: null, - })) as TrailComment[] - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Kommentare' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('trail_comments').subscribe('*', (e) => { - const record = e.record as TrailComment - const idx = this.records.findIndex((r) => r.id === record.id) - if (e.action === 'create' && idx === -1) this.records = [record, ...this.records] - else if (e.action === 'update' && idx !== -1) this.records[idx] = record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== record.id) - }, { expand: 'created_by' }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('trail_comments subscribe failed:', e)) - } - - async create(trailId: string, teamId: string, text: string) { - return await api.collection('trail_comments').create({ - trail: trailId, - team: teamId, - text: text.trim(), - created_by: auth.user?.id, - }) - } - - async remove(id: string) { - await api.collection('trail_comments').delete(id) - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setTrailCommentContext() { - const store = new TrailCommentStore() - setContext(KEY, store) - return store -} - -export function getTrailCommentContext(): TrailCommentStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/trailFlags.svelte.ts b/frontend/src/lib/stores/trailFlags.svelte.ts deleted file mode 100644 index 916ad9b..0000000 --- a/frontend/src/lib/stores/trailFlags.svelte.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api, auth } from './pocketbase.svelte' -import { getTeamContext } from './teams.svelte' -import type { TrailFlagsResponse } from '$lib/types' - -const KEY = Symbol('trailFlag') - -/** - * Vorschlag für einen Standardsatz. Bewusst NICHT in der Migration: Seed-Daten - * dort kämen bei jedem Containerstart zurück, auch nachdem sie jemand gelöscht - * hat. Stattdessen legt sie die Flag-Verwaltung auf Knopfdruck an. - * - * Der Abgleich läuft über das Label. Wird ein Standard-Flag umbenannt, legt - * ein erneuter Aufruf es wieder neu an. Das ist hinnehmbar, weil die - * Flag-Verwaltung den Knopf nur zeigt, solange noch gar keine Flags existieren. - * - * icon: Name eines Lucide-Icons (lucide-svelte) - */ -export const DEFAULT_FLAGS = [ - { label: 'Baum quer', icon: 'TreePine', color: '#ca8a04', severity: 'warnung' }, - { label: 'Verblockt', icon: 'Blocks', color: '#ea580c', severity: 'warnung' }, - { label: 'Erosion', icon: 'Waves', color: '#a16207', severity: 'warnung' }, - { label: 'Sperrung', icon: 'Ban', color: '#dc2626', severity: 'kritisch' }, - { label: 'Bauarbeiten', icon: 'Construction', color: '#dc2626', severity: 'kritisch' }, - { label: 'Hinweis', icon: 'Info', color: '#2563eb', severity: 'info' }, -] - -export class TrailFlagStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - - get scoped(): TrailFlagsResponse[] { - const teamId = this.teams.activeId - if (!teamId) return [] - return this.records.filter((r) => r.team === teamId) - } - - getById(id: string): TrailFlagsResponse | undefined { - return this.records.find((r) => r.id === id) - } - - /** - * Flag-Typen anlegen, ändern und löschen darf nur, wer das Team führt — - * dieselbe Regel wie in createRule/updateRule/deleteRule der Collection. - * Die Prüfung hier blendet nur UI aus; durchgesetzt wird sie serverseitig. - */ - get canManage(): boolean { - const uid = auth.user?.id - if (!uid) return false - - const team = this.teams.records.find((t) => t.id === this.teams.activeId) - if (!team) return false - - return team.owner === uid || ((team.admins ?? []) as string[]).includes(uid) - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('trail_flags').getFullList({ - sort: 'label', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Flags' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('trail_flags').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('trail_flags subscribe failed:', e)) - } - - async create(data: { label: string; icon: string; color: string; severity: string }) { - const teamId = this.teams.activeId - if (!teamId) throw new Error('Kein aktives Team') - return await api.collection('trail_flags').create({ ...data, team: teamId }) - } - - async edit(id: string, data: Partial) { - return await api.collection('trail_flags').update(id, data) - } - - async remove(id: string) { - await api.collection('trail_flags').delete(id) - } - - /** - * Legt die Standard-Flags an, die noch fehlen. Vorhandene bleiben - * unangetastet — der Knopf lässt sich also gefahrlos mehrfach drücken. - * Gibt die Anzahl neu angelegter Flags zurück. - */ - async seedDefaults(teamId = this.teams.activeId): Promise { - if (!teamId) throw new Error('Kein aktives Team') - // Beim Anlegen eines Teams ist man dessen Owner, aber der Datensatz - // steckt womöglich noch nicht im Store — dann greift die Prüfung ins - // Leere. Für das eigene, gerade erzeugte Team wird sie übersprungen; - // die createRule der Collection setzt sie ohnehin serverseitig durch. - if (teamId === this.teams.activeId && !this.canManage) { - throw new Error('Dafür brauchst du Administratorrechte im Team.') - } - - const existing = new Set( - this.records.filter((f) => f.team === teamId).map((f) => f.label), - ) - let created = 0 - - for (const flag of DEFAULT_FLAGS) { - if (existing.has(flag.label)) continue - try { - await api.collection('trail_flags').create({ ...flag, team: teamId }) - created++ - } catch (e: any) { - // Bricht mitten in der Schleife etwas ab, sollen die bereits - // angelegten Flags nicht verschwiegen werden. - throw new Error( - `${created} von ${DEFAULT_FLAGS.length} Typen angelegt, dann: ` + - (e.message ?? 'unbekannter Fehler'), - ) - } - } - - return created - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setTrailFlagContext() { - const store = new TrailFlagStore() - setContext(KEY, store) - return store -} - -export function getTrailFlagContext(): TrailFlagStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/trailMarkers.svelte.ts b/frontend/src/lib/stores/trailMarkers.svelte.ts deleted file mode 100644 index c546297..0000000 --- a/frontend/src/lib/stores/trailMarkers.svelte.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api, auth } from './pocketbase.svelte' -import { getTeamContext } from './teams.svelte' -import { isRecent } from '$lib/time' -import type { TrailMarkersResponse, UsersResponse } from '$lib/types' - -const KEY = Symbol('trailMarker') - -/** - * Marker samt aufgelöstem Melder. Ob `expand.created_by` gefüllt ist, hängt - * an den Sichtbarkeitsregeln der users-Collection — deshalb optional. - */ -export type TrailMarker = Omit & { - expand?: { created_by?: UsersResponse } -} - -export class TrailMarkerStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - - /** Alle Marker eines Trails, jüngste zuerst */ - byTrail(trailId: string): TrailMarker[] { - return this.records - .filter((r) => r.trail === trailId) - .sort((a, b) => (a.created < b.created ? 1 : -1)) - } - - /** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */ - hasRecent(trailId: string, days?: number): boolean { - return this.byTrail(trailId).some((r) => isRecent(r.created, days)) - } - - /** Nur die offenen — was auf der Karte auffallen soll */ - openByTrail(trailId: string): TrailMarker[] { - return this.byTrail(trailId).filter((r) => !r.resolved) - } - - /** - * Ändern darf, wer den Marker angelegt hat, sowie Trail-Paten und - * Team-Admins — dieselbe Regel wie in der updateRule der Collection. - * Der Trail wird übergeben, weil der Store ihn nicht kennt. - */ - canEdit(marker: TrailMarker, stewards: string[], teamOwner?: string, teamAdmins: string[] = []): boolean { - const uid = auth.user?.id - if (!uid) return false - - return ( - marker.created_by === uid || - stewards.includes(uid) || - teamOwner === uid || - teamAdmins.includes(uid) - ) - } - - /** - * Wer den Marker gemeldet hat. Fremde User-Datensätze sind derzeit nicht - * sichtbar (users.listRule: `id = @request.auth.id`), deshalb bleibt der - * Name bei Meldungen anderer offen. - */ - authorName(marker: TrailMarker): string { - const name = marker.expand?.created_by?.name - if (name) return name - if (marker.created_by && marker.created_by === auth.user?.id) return 'Du' - return 'Unbekannt' - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('trail_markers').getFullList({ - sort: '-created', - expand: 'created_by', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Marker' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('trail_markers').subscribe('*', (e) => { - // Realtime liefert den Datensatz ungetypt; angefordert wird unten - // dasselbe expand wie beim Laden. - const record = e.record as TrailMarker - const idx = this.records.findIndex((r) => r.id === record.id) - if (e.action === 'create' && idx === -1) this.records = [record, ...this.records] - else if (e.action === 'update' && idx !== -1) this.records[idx] = record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) - }, { expand: 'created_by' }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('trail_markers subscribe failed:', e)) - } - - /** - * `flag` ist optional: Ein Marker darf mit eigenem `label` und `icon` - * auskommen, wenn kein vorgefertigter Typ passt. - */ - async create(data: { - trail: string - flag?: string - label?: string - icon?: string - lat: number - lng: number - note?: string - }) { - const teamId = this.teams.activeId - if (!teamId) throw new Error('Kein aktives Team') - - return await api.collection('trail_markers').create({ - ...data, - team: teamId, - resolved: false, - created_by: auth.user?.id, - }) - } - - async edit(id: string, data: Partial) { - return await api.collection('trail_markers').update(id, data) - } - - /** Erledigt statt gelöscht — die Meldung bleibt als Historie erhalten. */ - async toggleResolved(id: string) { - const marker = this.records.find((r) => r.id === id) - if (!marker) throw new Error('Marker nicht gefunden') - await api.collection('trail_markers').update(id, { resolved: !marker.resolved }) - } - - async remove(id: string) { - await api.collection('trail_markers').delete(id) - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setTrailMarkerContext() { - const store = new TrailMarkerStore() - setContext(KEY, store) - return store -} - -export function getTrailMarkerContext(): TrailMarkerStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/trailVersions.svelte.ts b/frontend/src/lib/stores/trailVersions.svelte.ts deleted file mode 100644 index 410d39f..0000000 --- a/frontend/src/lib/stores/trailVersions.svelte.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api, auth } from './pocketbase.svelte' -import { parseGpx } from '$lib/gpx' -import type { TrailVersionsResponse } from '$lib/types' - -const KEY = Symbol('trailVersion') - -/** Größer als das maxSize des gpx-Feldes wäre serverseitig ohnehin abgelehnt. */ -const MAX_GPX_BYTES = 10 * 1024 * 1024 - -export class TrailVersionStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - - /** Versionen eines Trails, jüngste zuerst */ - byTrail(trailId: string): TrailVersionsResponse[] { - return this.records - .filter((r) => r.trail === trailId) - .sort((a, b) => (a.created < b.created ? 1 : -1)) - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('trail_versions').getFullList({ - sort: '-created', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Versionen' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('trail_versions').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('trail_versions subscribe failed:', e)) - } - - /** - * Liest die GPX-Datei im Browser, legt eine Version an und macht sie zur - * aktiven. Die Originaldatei wird mitgespeichert, damit sie später - * heruntergeladen werden kann. - */ - async upload(trailId: string, file: File, note: string) { - if (file.size > MAX_GPX_BYTES) { - throw new Error('Die Datei ist größer als 10 MB.') - } - - const text = await file.text() - const parsed = parseGpx(text) // wirft mit verständlicher Meldung - - const form = new FormData() - form.append('trail', trailId) - form.append('gpx', file) - form.append('geojson', JSON.stringify(parsed.geojson)) - form.append('coord_distances', JSON.stringify(parsed.coordDistances)) - form.append('elevation', JSON.stringify(parsed.elevation)) - form.append('bounds', JSON.stringify(parsed.bounds)) - form.append('distance_m', String(parsed.distance_m)) - form.append('ascent_m', String(parsed.ascent_m)) - form.append('note', note) - if (auth.user?.id) form.append('uploaded_by', auth.user.id) - - const version = await api.collection('trail_versions').create(form) - - // Eine frisch hochgeladene Version ist immer die aktive. - await api.collection('trails').update(trailId, { current: version.id }) - - return version - } - - /** - * Schaltet auf eine ältere Version zurück. Prüft vorher, dass die Version - * zu diesem Trail gehört — trails.current könnte sonst auf eine fremde - * Version zeigen. - */ - async activate(trailId: string, versionId: string) { - const version = this.records.find((r) => r.id === versionId) - if (!version) throw new Error('Version nicht gefunden') - if (version.trail !== trailId) { - throw new Error('Die Version gehört nicht zu diesem Trail') - } - - await api.collection('trails').update(trailId, { current: versionId }) - } - - async remove(id: string) { - await api.collection('trail_versions').delete(id) - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setTrailVersionContext() { - const store = new TrailVersionStore() - setContext(KEY, store) - return store -} - -export function getTrailVersionContext(): TrailVersionStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/stores/trails.svelte.ts b/frontend/src/lib/stores/trails.svelte.ts deleted file mode 100644 index 6769880..0000000 --- a/frontend/src/lib/stores/trails.svelte.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { getContext, setContext } from 'svelte' -import { api } from './pocketbase.svelte' -import { auth } from './pocketbase.svelte' -import { getTeamContext } from './teams.svelte' -import type { TrailsResponse } from '$lib/types' - -const KEY = Symbol('trail') - -export class TrailStore { - records = $state([]) - loading = $state(false) - error = $state(null) - - private unsubscribe: (() => void) | null = null - private teams = getTeamContext() - - /** Records gefiltert nach aktivem Team */ - get scoped(): TrailsResponse[] { - const teamId = this.teams.activeId - if (!teamId) return [] - return this.records.filter((r) => r.team === teamId) - } - - getById(id: string): TrailsResponse | undefined { - return this.records.find((r) => r.id === id) - } - - /** - * Bearbeiten dürfen Trail-Paten sowie Owner und Admins des Teams — - * dieselbe Regel wie in der updateRule der Collection. Die Prüfung hier - * blendet nur UI aus; durchgesetzt wird sie serverseitig. - */ - canEdit(trail: TrailsResponse): boolean { - const uid = auth.user?.id - if (!uid) return false - - const team = this.teams.records.find((t) => t.id === trail.team) - const stewards = (trail.stewards ?? []) as string[] - - return ( - stewards.includes(uid) || - team?.owner === uid || - ((team?.admins ?? []) as string[]).includes(uid) - ) - } - - async load() { - this.loading = true - this.error = null - try { - this.records = await api.collection('trails').getFullList({ - sort: 'name', - requestKey: null, - }) - } catch (e: any) { - this.error = e.message ?? 'Fehler beim Laden der Trails' - console.error(e) - } finally { - this.loading = false - } - } - - subscribe() { - if (this.unsubscribe) return - api.collection('trails').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record - else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) - }).then((unsub) => { - this.unsubscribe = unsub - }).catch((e) => console.warn('trails subscribe failed:', e)) - } - - async create(data: { name: string; description?: string; status?: string }) { - const teamId = this.teams.activeId - if (!teamId) throw new Error('Kein aktives Team') - - const uid = auth.user?.id - // Wer anlegt, wird automatisch erster Pate — sonst könnte niemand - // den frisch angelegten Trail bearbeiten. - return await api.collection('trails').create({ - ...data, - status: data.status ?? 'offen', - team: teamId, - created_by: uid, - stewards: uid ? [uid] : [], - }) - } - - async edit(id: string, data: Partial) { - return await api.collection('trails').update(id, data) - } - - /** - * Status melden. Wann und von wem kommt gleich mit — ohne das ließe sich - * einer Sperrung nicht ansehen, ob sie von heute oder vom letzten Jahr ist. - */ - async setStatus(id: string, status: string) { - return await api.collection('trails').update(id, { - status, - status_changed: new Date().toISOString(), - status_by: auth.user?.id ?? '', - }) - } - - async setStewards(id: string, userIds: string[]) { - return await api.collection('trails').update(id, { stewards: userIds }) - } - - async remove(id: string) { - await api.collection('trails').delete(id) - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } -} - -export function setTrailContext() { - const store = new TrailStore() - setContext(KEY, store) - return store -} - -export function getTrailContext(): TrailStore { - return getContext(KEY) -} diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts deleted file mode 100644 index 901e7e2..0000000 --- a/frontend/src/lib/time.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { formatDistanceToNow } from 'date-fns' -import { de } from 'date-fns/locale' - -/** - * Formatierung von Zeiten. Bewusst hier und nicht im times-Store: Auch - * Komponenten ohne Store-Zugriff zeigen Dauern an, und die Darstellung soll - * überall dieselbe sein. - */ - -/** Dauer in Sekunden als `m:ss.s`. */ -export function formatDuration(seconds: number | undefined): string { - if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '0:00.0' - - const m = Math.floor(seconds / 60) - const s = (seconds % 60).toFixed(1) - - return `${m}:${s.padStart(4, '0')}` -} - -/** Beschriftungen der Zeit-Status aus der Collection. */ -export const TIME_STATUS_LABEL: Record = { - active: 'Läuft', - finished: 'Beendet', - dnf: 'DNF', - dns: 'DNS', - dsq: 'DSQ', -} - -/** - * Ein PocketBase-Zeitstempel als Date. Die kommen als - * `2026-09-12 08:30:00.000Z` mit Leerzeichen statt `T` — die meisten Browser - * schlucken das, garantiert ist es nicht. - */ -function parseDate(value: string | undefined): Date | null { - if (!value) return null - - const date = new Date(value.replace(' ', 'T')) - return Number.isNaN(date.getTime()) ? null : date -} - -/** Zeitpunkt als `TT.MM.JJJJ, HH:MM`. */ -export function formatDateTime(iso: string | undefined): string { - const date = parseDate(iso) - if (!date) return '—' - return date.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) -} - -/** - * Zeitpunkt als Abstand zu jetzt: „vor 3 Tagen". - * - * Für alles, woran die Frage „wie frisch ist das?" hängt. Ein Datum muss man - * erst gegen den heutigen Tag rechnen, um zu wissen, ob eine Sperrung von - * gestern stammt oder vom letzten Sommer — der Abstand sagt es sofort. Das - * genaue Datum gehört daneben in einen Tooltip, es geht dadurch nicht - * verloren. - */ -export function formatRelative(value: string | undefined): string { - const date = parseDate(value) - if (!date) return '—' - - return formatDistanceToNow(date, { addSuffix: true, locale: de }) -} - -/** Heutiges Datum als `JJJJ-MM-TT` in lokaler Zeit (nicht UTC). */ -export function todayISO(): string { - const d = new Date() - const m = String(d.getMonth() + 1).padStart(2, '0') - const day = String(d.getDate()).padStart(2, '0') - return `${d.getFullYear()}-${m}-${day}` -} - -/** - * Datumsanteil eines PocketBase-Werts. Die kommen als - * `2026-09-12 00:00:00.000Z` zurück; für Vergleiche und `` - * zählt nur der Tag. - */ -export function dayOf(value: string | undefined): string { - if (!value) return '' - return value.slice(0, 10) -} - -/** Ein Tag als `TT.MM.JJJJ`. */ -export function formatDay(value: string | undefined): string { - const day = dayOf(value) - if (!day) return '' - - const [y, m, d] = day.split('-') - return `${d}.${m}.${y}` -} - -/** - * Zeitraum eines Events. Ohne Enddatum oder bei gleichem Tag steht nur ein - * Datum da; sonst „12.09.2026 – 14.09.2026". - */ -export function formatDateRange(starts: string | undefined, ends: string | undefined): string { - const from = formatDay(starts) - const to = formatDay(ends) - - if (!from && !to) return '' - if (!from) return to - if (!to || to === from) return from - - return `${from} – ${to}` -} - -/** - * Standardfenster für „neu" in Übersichten. Zwei Wochen, weil ein Trail - * selten öfter befahren wird — kürzer wäre die Hervorhebung meist leer. - */ -export const RECENT_DAYS = 14 - -/** Liegt der Zeitpunkt innerhalb der letzten `days` Tage? */ -export function isRecent(value: string | undefined, days = RECENT_DAYS): boolean { - if (!value) return false - - const then = new Date(value).getTime() - if (!Number.isFinite(then)) return false - - return Date.now() - then <= days * 24 * 60 * 60 * 1000 -} diff --git a/frontend/src/lib/trailStatus.ts b/frontend/src/lib/trailStatus.ts deleted file mode 100644 index 8fe0375..0000000 --- a/frontend/src/lib/trailStatus.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Zustände eines Trails samt Farbe und Beschriftung. - * - * An einer Stelle, weil dieselben Farben in der Kartenlinie, im Abzeichen und - * im Auswahlmenü auftauchen — liefen sie auseinander, hieße Grün auf der Karte - * etwas anderes als Grün daneben. - */ -export type TrailStatusValue = 'offen' | 'eingeschraenkt' | 'gesperrt' - -export type TrailStatusInfo = { - value: TrailStatusValue - label: string - /** Als CSS-Farbe, auch für MapLibre-Paint-Eigenschaften verwendbar. */ - color: string - /** Kurzer Hinweis, was der Zustand bedeutet. */ - hint: string -} - -export const TRAIL_STATUS: TrailStatusInfo[] = [ - { value: 'offen', label: 'Offen', color: '#16a34a', hint: 'befahrbar' }, - { - value: 'eingeschraenkt', - label: 'Eingeschränkt', - color: '#ca8a04', - hint: 'befahrbar, aber mit Hindernissen', - }, - { value: 'gesperrt', label: 'Gesperrt', color: '#dc2626', hint: 'nicht befahren' }, -] - -const BY_VALUE = new Map(TRAIL_STATUS.map((s) => [s.value, s])) - -export function trailStatus(value: string | undefined): TrailStatusInfo { - return (value && BY_VALUE.get(value as TrailStatusValue)) || TRAIL_STATUS[0] -} - -export function trailStatusColor(value: string | undefined): string { - return trailStatus(value).color -} diff --git a/frontend/src/lib/types.d.ts b/frontend/src/lib/types.d.ts deleted file mode 100644 index 3dfdf10..0000000 --- a/frontend/src/lib/types.d.ts +++ /dev/null @@ -1,346 +0,0 @@ -/** -* This file was @generated using pocketbase-typegen -*/ - -import type PocketBase from 'pocketbase' -import type { RecordService } from 'pocketbase' - -export enum Collections { - EventParticipants = "event_participants", - EventSeries = "event_series", - Events = "events", - Riders = "riders", - Stages = "stages", - TeamInvites = "team_invites", - Teams = "teams", - Times = "times", - TrailComments = "trail_comments", - TrailFlags = "trail_flags", - TrailMarkers = "trail_markers", - TrailVersions = "trail_versions", - Trails = "trails", - Users = "users", -} - -// Alias types for improved usability -export type IsoDateString = string -export type RecordIdString = string -export type HTMLString = string - -type ExpandType = unknown extends T - ? T extends unknown - ? { expand?: unknown } - : { expand: T } - : { expand: T } - -// System fields -export type BaseSystemFields = { - id: RecordIdString - collectionId: string - collectionName: Collections -} & ExpandType - -export type AuthSystemFields = { - email: string - emailVisibility: boolean - username: string - verified: boolean -} & BaseSystemFields - -// Record types for each collection - -export enum EventParticipantsStatusOptions { - "angefragt" = "angefragt", - "zugesagt" = "zugesagt", - "abgesagt" = "abgesagt", -} -export type EventParticipantsRecord = { - created?: IsoDateString - created_by?: RecordIdString - event: RecordIdString - id: string - number?: string - rider: RecordIdString - status?: EventParticipantsStatusOptions - updated?: IsoDateString -} - -export enum EventSeriesRuleOptions { - "woechentlich" = "woechentlich", - "zweiwoechentlich" = "zweiwoechentlich", - "monatlich" = "monatlich", -} - -export enum EventSeriesParticipationOptions { - "offen" = "offen", - "bestaetigung" = "bestaetigung", - "geschlossen" = "geschlossen", -} -export type EventSeriesRecord = { - created?: IsoDateString - created_by?: RecordIdString - description?: string - id: string - location?: string - name: string - participation?: EventSeriesParticipationOptions - rule?: EventSeriesRuleOptions - starts?: IsoDateString - team: RecordIdString - timing?: boolean - until?: IsoDateString - updated?: IsoDateString -} - -export enum EventsParticipationOptions { - "offen" = "offen", - "bestaetigung" = "bestaetigung", - "geschlossen" = "geschlossen", -} -export type EventsRecord = { - created?: IsoDateString - created_by?: RecordIdString - description?: string - ends?: IsoDateString - gps?: string - id: string - location?: string - name?: string - participation?: EventsParticipationOptions - series?: RecordIdString - starts?: IsoDateString - team?: RecordIdString - timing?: boolean - updated?: IsoDateString -} - -export type RidersRecord = { - avatar?: string - created?: IsoDateString - id: string - name?: string - team?: RecordIdString - updated?: IsoDateString - user?: RecordIdString -} - -export type StagesRecord = { - created?: IsoDateString - description?: string - event?: RecordIdString - id: string - team?: RecordIdString - updated?: IsoDateString -} - -export type TeamInvitesRecord = { - created?: IsoDateString - created_by?: RecordIdString - expires?: IsoDateString - id: string - max_uses?: number - team: RecordIdString - token: string - updated?: IsoDateString - uses?: number -} - -export type TeamsRecord = { - admins?: RecordIdString[] - created?: IsoDateString - id: string - logo?: string - name: string - owner: RecordIdString - updated?: IsoDateString - users?: RecordIdString[] -} - -export enum TimesStatusOptions { - "active" = "active", - "finished" = "finished", - "dnf" = "dnf", - "dns" = "dns", - "dsq" = "dsq", -} -export type TimesRecord = { - comment?: string - correction?: number - created?: IsoDateString - id: string - rider?: RecordIdString - stage?: RecordIdString - start?: IsoDateString - startedBy?: RecordIdString - status?: TimesStatusOptions - stop?: IsoDateString - stoppedBy?: RecordIdString - team?: RecordIdString - updated?: IsoDateString -} - -export type TrailCommentsRecord = { - created?: IsoDateString - created_by?: RecordIdString - id: string - team: RecordIdString - text: string - trail: RecordIdString - updated?: IsoDateString -} - -export enum TrailFlagsSeverityOptions { - "info" = "info", - "warnung" = "warnung", - "kritisch" = "kritisch", -} -export type TrailFlagsRecord = { - color?: string - created?: IsoDateString - icon?: string - id: string - label: string - severity?: TrailFlagsSeverityOptions - team: RecordIdString - updated?: IsoDateString -} - -export type TrailMarkersRecord = { - created?: IsoDateString - created_by?: RecordIdString - flag?: RecordIdString - icon?: string - id: string - label?: string - lat: number - lng: number - note?: string - resolved?: boolean - team: RecordIdString - trail: RecordIdString - updated?: IsoDateString -} - -export type TrailVersionsRecord = { - ascent_m?: number - bounds?: null | Tbounds - coord_distances?: null | Tcoord_distances - created?: IsoDateString - distance_m?: number - elevation?: null | Televation - geojson?: null | Tgeojson - gpx?: string - id: string - note?: string - trail: RecordIdString - updated?: IsoDateString - uploaded_by?: RecordIdString -} - -export enum TrailsStatusOptions { - "offen" = "offen", - "eingeschraenkt" = "eingeschraenkt", - "gesperrt" = "gesperrt", -} -export type TrailsRecord = { - created?: IsoDateString - created_by?: RecordIdString - current?: RecordIdString - description?: HTMLString - event?: RecordIdString - id: string - name: string - status?: TrailsStatusOptions - status_by?: RecordIdString - status_changed?: IsoDateString - stewards?: RecordIdString[] - team: RecordIdString - updated?: IsoDateString -} - -export type UsersRecord = { - avatar?: string - created?: IsoDateString - email?: string - emailVisibility?: boolean - id: string - name?: string - password: string - superadmin?: boolean - tokenKey: string - updated?: IsoDateString - username: string - verified?: boolean -} - -// Response types include system fields and match responses from the PocketBase API -export type EventParticipantsResponse = Required & BaseSystemFields -export type EventSeriesResponse = Required & BaseSystemFields -export type EventsResponse = Required & BaseSystemFields -export type RidersResponse = Required & BaseSystemFields -export type StagesResponse = Required & BaseSystemFields -export type TeamInvitesResponse = Required & BaseSystemFields -export type TeamsResponse = Required & BaseSystemFields -export type TimesResponse = Required & BaseSystemFields -export type TrailCommentsResponse = Required & BaseSystemFields -export type TrailFlagsResponse = Required & BaseSystemFields -export type TrailMarkersResponse = Required & BaseSystemFields -export type TrailVersionsResponse = Required> & BaseSystemFields -export type TrailsResponse = Required & BaseSystemFields -export type UsersResponse = Required & AuthSystemFields - -// Types containing all Records and Responses, useful for creating typing helper functions - -export type CollectionRecords = { - event_participants: EventParticipantsRecord - event_series: EventSeriesRecord - events: EventsRecord - riders: RidersRecord - stages: StagesRecord - team_invites: TeamInvitesRecord - teams: TeamsRecord - times: TimesRecord - trail_comments: TrailCommentsRecord - trail_flags: TrailFlagsRecord - trail_markers: TrailMarkersRecord - trail_versions: TrailVersionsRecord - trails: TrailsRecord - users: UsersRecord -} - -export type CollectionResponses = { - event_participants: EventParticipantsResponse - event_series: EventSeriesResponse - events: EventsResponse - riders: RidersResponse - stages: StagesResponse - team_invites: TeamInvitesResponse - teams: TeamsResponse - times: TimesResponse - trail_comments: TrailCommentsResponse - trail_flags: TrailFlagsResponse - trail_markers: TrailMarkersResponse - trail_versions: TrailVersionsResponse - trails: TrailsResponse - users: UsersResponse -} - -// Type for usage with type asserted PocketBase instance -// https://github.com/pocketbase/js-sdk#specify-typescript-definitions - -export type TypedPocketBase = PocketBase & { - collection(idOrName: 'event_participants'): RecordService - collection(idOrName: 'event_series'): RecordService - collection(idOrName: 'events'): RecordService - collection(idOrName: 'riders'): RecordService - collection(idOrName: 'stages'): RecordService - collection(idOrName: 'team_invites'): RecordService - collection(idOrName: 'teams'): RecordService - collection(idOrName: 'times'): RecordService - collection(idOrName: 'trail_comments'): RecordService - collection(idOrName: 'trail_flags'): RecordService - collection(idOrName: 'trail_markers'): RecordService - collection(idOrName: 'trail_versions'): RecordService - collection(idOrName: 'trails'): RecordService - collection(idOrName: 'users'): RecordService -} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts deleted file mode 100644 index 55b3a91..0000000 --- a/frontend/src/lib/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChild = T extends { child?: any } ? Omit : T; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChildren = T extends { children?: any } ? Omit : T; -export type WithoutChildrenOrChild = WithoutChildren>; -export type WithElementRef = T & { ref?: U | null }; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 98a1e6b..dc91a93 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,35 +1,14 @@ - - - - - - -{#if ready} - {@render children?.()} - -{/if} +{@render children?.()} diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..15b27a1 --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,15 @@ +/** + * Alles vorrendern, nichts nachladen. + * + * Drei Seiten ohne eine einzige dynamische Angabe — die entstehen beim Bauen + * einmal als HTML und werden danach von einem Webserver ausgeliefert. Das + * spart nicht nur den laufenden Node-Prozess; es ist auch das, was eine + * Link-Vorschau in WhatsApp oder Teams überhaupt lesen kann, denn die führt + * kein JavaScript aus. + * + * `ssr` bleibt an (sonst gäbe es nichts vorzurendern), `csr` ebenfalls: Ohne + * Client-JavaScript wären die drei internen Links volle Seitenladungen. Das + * wäre zu verschmerzen, aber die paar Kilobyte sind es auch. + */ +export const prerender = true +export const trailingSlash = 'always' diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index c4539d6..0c83146 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,14 +1,89 @@ -
- -
+ + Stammtisch Hersbruck + + + + + + + + + +
+ + Wappen des Stammtisch Hersbruck + +

Stammtisch Hersbruck

+ +

+ Zeiten, Trails und Renntage laufen jetzt auf + Kurbeler. Dein Zugang gilt dort + weiter, und mitgezogen ist alles. +

+ + + Zu Kurbeler + + + + + +
diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte deleted file mode 100644 index e7d652e..0000000 --- a/frontend/src/routes/dashboard/+layout.svelte +++ /dev/null @@ -1,409 +0,0 @@ - - -{#if auth.user} -
- - - - -
-
-
- -
- (dashHover = true)} - onmouseleave={() => (dashHover = false)} - > - - - - - -
- - -
- - - -
-
-
- - {#if isMobileMenuOpen} -
- -
- {/if} -
- -
- {@render children?.()} -
- - - - - -
-{/if} diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte deleted file mode 100644 index 6251b4c..0000000 --- a/frontend/src/routes/dashboard/+page.svelte +++ /dev/null @@ -1,128 +0,0 @@ - - - - Dashboard – Zeitnahme - - -
-
-

Willkommen, {auth.user?.name || auth.user?.email}!

- {#if teams.active} -

- Team {teams.active.name} · {stats.totalEvents} Events, {stats.totalRiders} Fahrer -

- {:else} -

Kein Team aktiv. Team verwalten.

- {/if} -
- -
- - - Events - - - -
{stats.totalEvents}
-

{stats.activeEvents} heute

-
-
- - - - Fahrer - - - -
{stats.totalRiders}
-

registriert

-
-
- - - - Laufende Zeiten - - - -
{stats.running}
-

{stats.finished} abgeschlossen

-
-
-
- -
-

Heute und demnächst

- {#if upNext.length === 0} - - - -

Nichts geplant

-

- Trag einem Event ein Datum ein, dann steht es hier. -

- -
-
- {:else} - - {/if} -
-
diff --git a/frontend/src/routes/dashboard/events/+page.svelte b/frontend/src/routes/dashboard/events/+page.svelte deleted file mode 100644 index 909416b..0000000 --- a/frontend/src/routes/dashboard/events/+page.svelte +++ /dev/null @@ -1,534 +0,0 @@ - - -Events – Zeitnahme - -
-
-
-

Events

-

Veranstaltungen und Rennen verwalten

-
- -
- - -
-
-
-

Serien

-

- Wiederkehrende Termine — jeder Termin entsteht als eigenes Event und - lässt sich einzeln absagen oder verschieben. -

-
- -
- - {#if series.scoped.length > 0} -
- {#each series.scoped as s (s.id)} - {@const angelegt = series.eventsOf(s.id).length} - {@const naechste = occurrences( - dayOf(s.starts), - (s.rule || 'woechentlich') as SeriesRule, - 3, - dayOf(s.until) || undefined, - )} - - -
-
-

{s.name}

-

- {SERIES_RULE_LABEL[s.rule] ?? ''} ab {formatDay(s.starts)} - {#if s.until}bis {formatDay(s.until)}{/if} -

-
-
- - -
-
- -

- {angelegt} Termin{angelegt === 1 ? '' : 'e'} angelegt - {#if naechste.length > 0} - · nächste: {naechste.map(formatDay).join(', ')} - {/if} -

- - -
-
- {/each} -
- {/if} -
- - {#if events.loading} -
Lade Events…
- {:else if events.scoped.length === 0} - - - -

Keine Events vorhanden

-

Erstelle dein erstes Event, um zu beginnen.

- -
-
- {:else} - {#each groups as group} - {#if group.items.length > 0} -
-

{group.title} ({group.items.length})

- -
- {/if} - {/each} - {/if} -
- - - - - {editing ? 'Event bearbeiten' : 'Neues Event'} - - Trage die Stammdaten ein. Mehr Details lassen sich später ergänzen. - - - -
{ - e.preventDefault() - save() - }} - class="space-y-4" - > -
- - -
-
- - -
-
- - -
-
- - -
- - - - {#if multiDay} -
- - -
- {/if} - - -

- Blendet Stages und Zeitnahme ein. Für Stammtisch oder Feierabendrunde - unnötig. -

- -
- - - - {PARTICIPATION_LABEL[form.participation] ?? 'Offen'} - - - Offen — jeder trägt sich selbst ein - - Mit Bestätigung — Anmeldung, du bestätigst - - - Geschlossen — du trägst Teilnehmer ein - - - -
- - {#if formError} -

{formError}

- {/if} - - - - - -
-
-
- - - - - - {seriesEditing ? 'Serie bearbeiten' : 'Neue Serie'} - - Die Serie ist die Vorlage. Termine entstehen daraus als eigene Events — - Änderungen hier gelten für künftige, nicht für bereits angelegte. - - - -
-
- - -
-
- - -
- -
- - - - {SERIES_RULE_LABEL[seriesForm.rule] ?? 'wöchentlich'} - - - wöchentlich - alle zwei Wochen - monatlich - - -
- -
-
- - -
-
- - -
-
- - - -
- - - - {PARTICIPATION_LABEL[seriesForm.participation] ?? 'Offen'} - - - Offen - Mit Bestätigung - Geschlossen - - -
- - {#if seriesError} -

{seriesError}

- {/if} -
- - - - - -
-
diff --git a/frontend/src/routes/dashboard/events/[id]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/+page.svelte deleted file mode 100644 index 716bff2..0000000 --- a/frontend/src/routes/dashboard/events/[id]/+page.svelte +++ /dev/null @@ -1,702 +0,0 @@ - - -{event?.name ?? 'Event'} – Zeitnahme - -{#if !event} - - - -

Event nicht gefunden

-

Vermutlich gehört es nicht zum aktiven Team.

- -
-
-{:else} -
-
-
- - - Eventliste - -
-

{event.name || 'Unbenannt'}

- {#if event.starts} - - - {formatDateRange(event.starts, event.ends)} - - {/if} -
- {#if event.location} -

- - {event.location} -

- {/if} - {#if event.description && event.description !== event.name} -

{event.description}

- {/if} -
- - -
- - - - -
-
-
-

- - Teilnehmer - ({eventParticipants.length}) -

-

- {#if event.participation === 'offen'} - Jedes Teammitglied kann sich selbst eintragen. - {:else if event.participation === 'bestaetigung'} - Anmeldungen werden vom Ersteller oder der Teamleitung bestätigt. - {:else} - Teilnehmer trägt der Ersteller oder die Teamleitung ein. - {/if} -

-
-
- {#if canSignUp} - - {/if} - {#if canManage} - - {/if} -
-
- - {#if canManage && available.length === 0 && riders.scoped.length === 0} -

- Das Team hat noch keine Fahrer. - Jetzt anlegen -

- {/if} - - - - {#if eventParticipants.length === 0} -
- -

Noch niemand dabei

-

- Fahrer gehören dem Team; hier steht, wer bei diesem Event - mitfährt. -

-
- {:else} - - - - # - Name - Status - {#if event.timing} - Zeiten - {/if} - Aktionen - - - - {#each eventParticipants as p (p.id)} - {@const rider = riders.getById(p.rider)} - {@const done = times.getByRider(p.rider).filter((t) => t.status === 'finished').length} - goto(riderPath(p.rider))}> - - {p.number || '—'} - - - - - {#if rider && riderName(rider)} - {riderName(rider)} - {:else} - — ohne Name — - {/if} - - - - - {PARTICIPANT_STATUS_LABEL[p.status] ?? p.status} - - - {#if event.timing} - - {done} von {eventStages.length} Stages - - {/if} - e.stopPropagation()}> - {#if canManage} - {#if p.status === 'angefragt'} - - {:else if p.status === 'zugesagt'} - - {:else} - - {/if} - - - {:else if p.id === ownEntry?.id} - - {/if} - - - {/each} - - - {/if} -
-
-
- - - {#if event.timing} -
-
-
-

- - Stages - ({eventStages.length}) -

-

Klick auf eine Stage öffnet die Zeitnahme

-
- -
- - - - {#if eventStages.length === 0} -
- -

Noch keine Stages

-

Lege den ersten Lauf an.

- -
- {:else} - - - - Bezeichnung - Zeiten - Aktionen - - - - {#each eventStages as stage (stage.id)} - {@const stageTimes = times.getByStage(stage.id)} - {@const active = stageTimes.filter((t) => t.status === 'active').length} - {@const ranked = stageTimes.filter((t) => t.status === 'finished').length} - goto(stagePath(stage.id))}> - - {stage.description} - - - {#if active > 0} - {active} laufend - {/if} - {ranked} gewertet - - e.stopPropagation()}> - - - - - {/each} - - - {/if} -
-
-
- {/if} -
-{/if} - - - - - - Startnummer ändern - - Die Startnummer gilt nur für „{event?.name}". - - - -
-
- - -

- Gilt je Event. Ohne Zeitnahme braucht es sie meist gar nicht. -

-
- - {#if formError} -

{formError}

- {/if} -
- - - - - -
-
- - - - - - Teilnehmer eintragen - - Aus dem Kader des Teams. Die Startnummer gilt je Event und kann leer - bleiben. - - - -
- {#if available.length === 0} -

- Der ganze Kader ist schon eingetragen. -

- {:else} -
-
- - -
- - -
- -
- {#each addFiltered as r (r.id)} -
- - (picks = { - ...picks, - [r.id]: { - number: picks[r.id]?.number ?? '', - chosen: e.currentTarget.checked, - }, - })} - /> - - - (picks = { - ...picks, - [r.id]: { - chosen: picks[r.id]?.chosen ?? false, - number: e.currentTarget.value, - }, - })} - /> -
- {:else} -

- Kein Treffer. -

- {/each} -
- {/if} - - {#if addError} -

{addError}

- {/if} -
- - - - - -
-
- - - - - - {stageEdit ? 'Stage bearbeiten' : 'Neue Stage'} - - Stage für „{event?.name}" - - -
{ - e.preventDefault() - saveStage() - }} - class="space-y-4" - > -
- - -
- - - - -
-
-
- - diff --git a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte deleted file mode 100644 index e21cbd9..0000000 --- a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte +++ /dev/null @@ -1,248 +0,0 @@ - - -{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} - -{#if !valid} - - - -

Fahrer nicht gefunden

-

- Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht. -

- -
-
-{:else if event && rider} -
-
-
- - - {event.name || 'Event'} - -
- {#if entry?.number} - {entry.number} - {/if} -

- {riderName(rider) || 'Ohne Name'} -

-
-
- - -
- -
- - - Gefahrene Stages - - -
{stats.done}/{stats.of}
-
-
- - - Bestzeit - - -
- {stats.best != null ? formatDuration(stats.best) : '—'} -
-
-
- - - Gesamtzeit - - -
- {stats.done > 0 ? formatDuration(stats.total) : '—'} -
-
-
-
- - - - - - Stages ({rows.length}) - - - - {#if rows.length === 0} -

- Für dieses Event gibt es noch keine Stages. -

- {:else} - - - - Stage - Status - Zeit - Platz - - - - - {#each rows as row (row.stage.id)} - goto(`/dashboard/events/${event.id}/stages/${row.stage.id}`)} - > - - - {row.stage.description || 'Stage'} - - - - {#if row.active} - Läuft - {:else if row.best} - Beendet - {:else if row.others.length > 0} - - {TIME_STATUS_LABEL[row.others[0].status] ?? row.others[0].status} - - {:else} - - {/if} - - - {#if row.active} - {row.active.formattedTime} - {:else if row.best} - {row.best.formattedTime} - {:else} - - {/if} - - - {#if row.placement} - - {row.placement} - - {:else} - - {/if} - - - - - - {/each} - - - {/if} -
-
-
-{/if} - - diff --git a/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte deleted file mode 100644 index f6e03e6..0000000 --- a/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte +++ /dev/null @@ -1,478 +0,0 @@ - - -{stage?.description ?? 'Stage'} – {event?.name ?? 'Event'} - -{#if !valid} - - - -

Stage nicht gefunden

-

- Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht. -

- -
-
-{:else if event && stage} -
-
-
- - - {event.name || 'Event'} - -

{stage.description || 'Stage'}

-

- {running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start -

-
- -
- - - {#if leaderboard.length > 0} - - {/if} - -
-
- - {#if starters.length === 0} - - - Für dieses Event hat noch niemand zugesagt. - Teilnehmer eintragen - - - {/if} - - - - -
- - - Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''}) - -
-
- - {#if rows.length === 0} -

- Noch keine Zeit. Über „Zeitnahme starten" geht es los. -

- {:else} - - - - Platz - # - Name - Zeit - Korrektur - - - - {#each rows as row (row.time.id)} - {@const t = row.time} - {@const r = rider(t.rider)} - {@const live = row.place === null} - - openTime(t.rider) : undefined} - > - - {#if live} - läuft - {:else} - - {row.place} - - {/if} - - - - {participants.numberOf(event.id, t.rider) || '—'} - - - - {#if r} - - e.stopPropagation()} - > - {riderName(r) || '—'} - - {:else} - — - {/if} - - - {#if userLabel(t.startedBy) || userLabel(t.stoppedBy)} - - {#if t.startedBy && t.stoppedBy && t.startedBy === t.stoppedBy} - Start und Stopp: {userLabel(t.startedBy)} - {:else} - {#if t.startedBy}Start: {userLabel(t.startedBy)}{/if} - {#if t.startedBy && t.stoppedBy} · {/if} - {#if t.stoppedBy}Stopp: {userLabel(t.stoppedBy)}{/if} - {/if} - - {/if} - - - - {#if live} - - {:else} - - {t.formattedTime} - - {/if} - - - - - - {/each} - - - {/if} -
-
- - {#if unranked.length > 0} - - - - - Ohne Wertung ({unranked.length}) - - - -
    - {#each unranked as t (t.id)} - {@const r = rider(t.rider)} -
  • -
    - - {participants.numberOf(event.id, t.rider) || '—'} - - - {#if r} - {riderName(r) || '—'} - {:else} - — - {/if} - -
    - {TIME_STATUS_LABEL[t.status] ?? t.status} -
  • - {/each} -
-
-
- {/if} -
-{/if} - - - - - - - - - Zeitkorrektur - - Wird auf die gemessene Zeit gerechnet. Eine Strafe ist positiv, eine - Gutschrift negativ. - - - -
-
- - -

- 0 nimmt eine bestehende Korrektur wieder zurück. -

-
- -
- - -

- Optional, aber nach dem Event ist eine Strafe ohne Grund schwer zu - verteidigen. -

-
- - {#if penaltyError} -

{penaltyError}

- {/if} -
- - - - - -
-
diff --git a/frontend/src/routes/dashboard/settings/+layout.svelte b/frontend/src/routes/dashboard/settings/+layout.svelte deleted file mode 100644 index 8c39ecb..0000000 --- a/frontend/src/routes/dashboard/settings/+layout.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - -Einstellungen - -
-

Einstellungen

- - - - {@render children?.()} -
diff --git a/frontend/src/routes/dashboard/settings/+page.svelte b/frontend/src/routes/dashboard/settings/+page.svelte deleted file mode 100644 index 08ef1ae..0000000 --- a/frontend/src/routes/dashboard/settings/+page.svelte +++ /dev/null @@ -1,354 +0,0 @@ - - - -
- - - - - Konto - {#if auth.user?.superadmin} - - Superadmin - {/if} - - - - -
- - -
- e.key === 'Enter' && e.currentTarget.blur()} - /> - -
-
- -
- {#if profileError} -

{profileError}

- {:else if savingProfile} -

Wird gespeichert …

- {:else if profileMessage} -

{profileMessage}

- {:else} - - {/if} - - -
-
-
- - - - - - - Konto löschen - - - - {#if ownedTeams.length > 0} -

- Du bist Owner von - {ownedTeams.map((t) => `„${t.name}"`).join(', ')}. Ein Team braucht - seinen Owner: Übergib es oder lösche es zuerst, danach lässt sich - dein Konto löschen. -

- - {:else} -

- Dein Konto wird endgültig gelöscht und du wirst abgemeldet. Dein - Fahrer bleibt mit allen Zeiten und Teilnahmen im Kader stehen — nur - anmelden kannst du dich nicht mehr. -

- - {#if deleteError} -

{deleteError}

- {/if} - - - {/if} -
-
-
- - - - - - Passwort ändern - - Nach der Änderung wirst du still neu angemeldet — PocketBase entwertet - mit dem Wechsel alle Tokens, auch den dieser Sitzung. - - - -
-
- - -
-
- - -

Mindestens 8 Zeichen.

-
-
- - -
- - {#if passwordError} -

{passwordError}

- {/if} -
- - - - - -
-
diff --git a/frontend/src/routes/dashboard/settings/flags/+page.svelte b/frontend/src/routes/dashboard/settings/flags/+page.svelte deleted file mode 100644 index d283833..0000000 --- a/frontend/src/routes/dashboard/settings/flags/+page.svelte +++ /dev/null @@ -1,217 +0,0 @@ - - -
-
-
-

- Wofür sich Marker auf einem Trail setzen lassen. -

- {#if !flags.canManage} -

- Flag-Typen verwaltet, wer das Team führt. Du kannst sie beim Setzen - von Markern verwenden. -

- {/if} -
- {#if flags.canManage} -
- {#if flags.scoped.length === 0} - - {/if} - -
- {/if} -
- - {#if flags.scoped.length === 0} - - - {#if flags.canManage} - Noch keine Flag-Typen. Leg einen an oder starte mit dem Standardsatz. - {:else} - Das Team hat noch keine Flag-Typen angelegt. - {/if} - - - {:else} -
- {#each flags.scoped as flag (flag.id)} - - - - {flag.label} - - {flag.severity} - - {#if flags.canManage} - - - {/if} - - - {/each} -
- {/if} -
- - - - - {editing ? 'Flag-Typ bearbeiten' : 'Neuer Flag-Typ'} - - -
-
- - -
-
- -
- - -
-
-
- -
- {#each SEVERITIES as s (s.value)} - - {/each} -
-
- - - {#if error} -

{error}

- {/if} -
- - - - - -
-
diff --git a/frontend/src/routes/dashboard/team/+page.svelte b/frontend/src/routes/dashboard/team/+page.svelte deleted file mode 100644 index f93b643..0000000 --- a/frontend/src/routes/dashboard/team/+page.svelte +++ /dev/null @@ -1,968 +0,0 @@ - - -Team - -
-
- -
-

{team?.name ?? 'Team'}

-

- {(team?.users ?? []).length} Mitglied{(team?.users ?? []).length === 1 ? '' : 'er'} - · {riders.scoped.length} im Kader -

-
- - - {#if team} -
- {#if canManage} - - - - {/if} - - {#if iAmOwner} - - {:else} - - {/if} -
- {/if} -
- - {#if !team} - - - -

Noch in keinem Team

-

Erstelle dein erstes Team.

- -
-
- {:else} -
- {#each stats as stat (stat.label)} - {@const Icon = stat.icon} - - -
-

{stat.label}

- -
-

{stat.value}

- {#if stat.hint} -

{stat.hint}

- {/if} -
-
- {/each} -
- - - - -
-
- - -
- {#if canManage} - - {/if} -
-
- - - {#if rows.length === 0} -
- -

Noch niemand im Team

-

- {#if canManage} - Leg die erste Person an oder verschick einen Einladungslink. - {:else} - Die Teamleitung legt den Kader an. - {/if} -

-
- {:else if filtered.length === 0} -
Keine Treffer.
- {:else} - - - - Name - Konto - Rolle - Events - Zeiten - {#if canManage} - Aktionen - {/if} - - - - {#each filtered as row (row.rider?.id ?? row.userId)} - {@const path = row.rider ? riderPath(row.rider.id) : null} - - - - - {#if path} - {row.name} - {:else} - {row.name} - {/if} - {#if !row.rider} - kein Fahrer - {/if} - - - - - - {#if row.userId} - - - kann sich anmelden - - {:else} - kein Konto - {/if} - - - - {#if row.role === 'owner'} - - - Owner - - {:else if row.role === 'admin'} - - - Admin - - {:else if row.role === 'mitglied'} - Mitglied - {:else} - - {/if} - - - - {row.rider ? participants.byRider(row.rider.id).length : '—'} - - - - {row.rider ? times.getByRider(row.rider.id).length : '—'} - - - {#if canManage} - - {#if row.rider && !row.userId} - - {/if} - - - - {#if row.userId && row.role !== 'owner'} - - {/if} - - {#if row.rider} - - {/if} - - {/if} - - {/each} - - - {/if} -
-
- {/if} - -
- - - - - - {isEdit ? 'Person bearbeiten' : 'Person anlegen'} - - {isEdit - ? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.' - : 'Kommt als Fahrer in den Kader. Ein Konto ist optional.'} - - - -
- {#if editing || !isEdit} -
- - -
- {/if} - - - {#if canSetRole} - -
- - -

- Admins verwalten Kader, Einladungslinks und Teamdaten. Löschen und - Rollen bleiben beim Owner. -

-
- {:else if isEdit && editingRole === 'owner'} - -

- Owner des Teams — diese Rolle lässt sich hier nicht ändern. -

- {/if} - - {#if !isEdit} - - - {#if form.withAccount} -
- - -
-
- - -

- Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt - sich unter „Einstellungen" ändern. -

-
- {/if} - {/if} - - {#if error} -

{error}

- {/if} -
- - - - - -
-
- - - - - - - Konto für {accountRider ? riderName(accountRider) : 'Fahrer'} - - - Mit einem Konto kann sich die Person selbst anmelden. Gib das Passwort - weiter; ändern lässt es sich unter „Einstellungen". - - - -
-
- - -
-
- - -
- - {#if accountError} -

{accountError}

- {/if} -
- - - - - -
-
- - - - - - Einladungslinks - - Wer den Link öffnet, legt sich selbst ein Konto an und steht danach im - Team {team?.name ?? ''} und im Kader. Gib ihn nur weiter, wem du das - zutraust — der Link ist das Passwort. - - - -
- {#if invites.scoped.length > 0} -
- {#each invites.scoped as invite (invite.id)} - -
-

- {invites.url(invite)} -

-
- - {inviteHint(invite)} - - - -
-
- {/each} -
- - {/if} - -
- - -

Bei 0 läuft der Link nicht ab.

-
-
- - -

- Bei 0 lässt er sich beliebig oft einlösen. -

-
- - {#if inviteError} -

{inviteError}

- {/if} -
- - - - - -
-
- - - - - - - - - Team umbenennen - -
-
- - -
- {#if renameError} -

{renameError}

- {/if} -
- - - - -
-
- - - - - - Logo für „{logoTeam?.name}" - - Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes - Logo steht dort das Wappen des Stammtischs. - - - -
-
- -
- - (logoFile = e.currentTarget.files?.[0] ?? null)} - /> -

PNG, SVG oder WebP, bis 5 MB.

-
-
- - {#if logoError} -

{logoError}

- {/if} -
- - - - {#if logoTeam?.logo} - - {/if} - - -
-
diff --git a/frontend/src/routes/dashboard/trails/+page.svelte b/frontend/src/routes/dashboard/trails/+page.svelte deleted file mode 100644 index 2e4146b..0000000 --- a/frontend/src/routes/dashboard/trails/+page.svelte +++ /dev/null @@ -1,255 +0,0 @@ - - -
-
-
-

Trails

-

- {trails.scoped.length} - {trails.scoped.length === 1 ? 'Trail' : 'Trails'} im Team -

-
- -
- - {#if trails.loading} -

Trails werden geladen …

- {:else if trails.scoped.length === 0} - - - -

- Noch kein Trail angelegt. Leg einen an und lade eine GPX-Datei hoch. -

- -
-
- {:else} -
- {#each trails.scoped as trail (trail.id)} - {@const version = currentVersion(trail.id)} - {@const open = markers.openByTrail(trail.id).length} - {@const status = trailStatus(trail.status)} - {@const markerCount = markers.byTrail(trail.id).length} - {@const commentCount = comments.byTrail(trail.id).length} - {@const freshMarkers = markers.hasRecent(trail.id)} - {@const freshComments = comments.hasRecent(trail.id)} - - - -
- {trail.name} - - - {status.label} - -
-
- - {#if version?.geojson} - - {:else} -
- Noch keine GPX-Datei -
- {/if} - - -
- - - {formatKm(version?.distance_m)} - - - - {version?.ascent_m ? `${version.ascent_m} hm` : '–'} - - - - {markerCount} - {#if freshMarkers} - - {/if} - - - - {commentCount} - {#if freshComments} - - {/if} - - {#if open > 0} - - - {open} - - {/if} -
-
-
-
- {/each} -
- {/if} -
- - - - - Neuer Trail - - Die GPX-Datei lädst du im nächsten Schritt hoch. - - - -
-
- - e.key === 'Enter' && save()} - /> -
-
- - -
- {#if error} -

{error}

- {/if} -
- - - - - -
-
diff --git a/frontend/src/routes/dashboard/trails/[id]/+page.svelte b/frontend/src/routes/dashboard/trails/[id]/+page.svelte deleted file mode 100644 index 5f4a3ce..0000000 --- a/frontend/src/routes/dashboard/trails/[id]/+page.svelte +++ /dev/null @@ -1,716 +0,0 @@ - - -{#if !trail} -
- -

Trail nicht gefunden.

-
-{:else} -
- -
- - -
-

{trail.name}

- - {#if canEdit} - - - {#snippet child({ props })} - - - {/snippet} - - - Zustand melden - {#each TRAIL_STATUS as s (s.value)} - setStatus(s.value)} - class={s.value === trail.status ? 'bg-accent' : ''} - > - - {s.label} - {s.hint} - - {/each} - - - {:else} - - - {statusInfo.label} - - {/if} - - {#if version} - - - - {(version.distance_m / 1000).toFixed(1)} km - - - - {version.ascent_m} hm - - - {/if} -
- - {#if trail.status_changed} - -

- {statusInfo.label} - gemeldet - - {formatRelative(trail.status_changed)} - - {#if trail.status_by} - von {teams.userLabel(trail.status_by)} - {/if} -

- {/if} - - {#if safeDescription} -

{@html safeDescription}

- {/if} -
- -
- - - - {#if version?.geojson} - (highlight = d)} - /> - - {#if version.elevation?.length} - (highlight = d)} - onselect={canPlace ? placeFromProfile : undefined} - /> - {/if} - {:else} -
- -

Für diesen Trail gibt es noch keine GPX-Datei.

- {#if canEdit} - - {/if} -
- {/if} -
-
- - -
- - - - - Marker ({trailMarkers.length}) - - - -

- Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen - Marker zu setzen. -

- - {#if trailMarkers.length === 0} -

Noch keine Meldung.

- {:else} -
    - {#each trailMarkers as m (m.id)} - {@const look = markerLook(m)} -
  • - -
    -

    {look.label}

    - {#if m.note} -

    {m.note}

    - {/if} -

    - {formatDate(m.created)} · {markers.authorName(m)} -

    -
    - {#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])} -
    - - -
    - {/if} -
  • - {/each} -
- {/if} -
-
- - - - - - Kommentare ({comments.length}) - - - -
- e.key === 'Enter' && addComment()} - /> - -
- {#if commentError} -

{commentError}

- {/if} - - {#if comments.length === 0} -

Noch keine Kommentare.

- {:else} -
    - {#each comments as c (c.id)} - {@const author = c.expand?.created_by} -
  • - - - - -
    -

    {c.text}

    -

    {formatDate(c.created)}

    -
    -
  • - {/each} -
- {/if} -
-
- - - - - - {#if showVersions} - - {#if canEdit} - - {/if} - - {#if trailVersions.length === 0} -

Noch keine Version hochgeladen.

- {:else} -
    - {#each trailVersions as v (v.id)} -
  • -
    -
    -

    {formatDate(v.created)}

    - {#if v.id === trail.current} - Aktiv - {/if} -
    -

    - {(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm - {#if v.note}· {v.note}{/if} -

    -
    - - {#if v.gpx} - - {/if} - - {#if canEdit && v.id !== trail.current} - - {/if} -
  • - {/each} -
- {/if} -
- {/if} -
-
-
-
-{/if} - - - - - - GPX-Datei hochladen - - Die bisherige Version bleibt erhalten; die neue wird zur aktiven. - - - -
-
- - (uploadFile = e.currentTarget.files?.[0] ?? null)} - /> -
-
- - -
- {#if uploadError} -

{uploadError}

- {/if} -
- - - - - -
-
- - - - - - Marker setzen - {#if markerDistance != null} - - bei km {(markerDistance / 1000).toFixed(2)} des Trails - - {/if} - - -
-
- - - - {#if selectedFlag} - - - {selectedFlag.label} - - {:else} - Eigener Typ - {/if} - - - Eigener Typ … - {#each flags.scoped as f (f.id)} - - - {f.label} - - {/each} - - -
- - {#if !markerFlag} -
- - -
- {/if} - - -
- - -
- {#if markerError} -

{markerError}

- {/if} -
- - - - - -
-
diff --git a/frontend/src/routes/imprint/+page.svelte b/frontend/src/routes/imprint/+page.svelte new file mode 100644 index 0000000..8ad588f --- /dev/null +++ b/frontend/src/routes/imprint/+page.svelte @@ -0,0 +1,66 @@ + + + +

Angaben gemäß § 5 DDG

+ +
+ Daniel Michelberger
+ Am Roten Bühl 11
+ 91247 Vorra +
+ +

Kontakt

+ +

E-Mail: hallo@kurbeler.de

+ +

Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV

+ +

Daniel Michelberger, Anschrift wie oben.

+ +

Verbraucherstreitbeilegung

+ +

+ Ich bin nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer + Verbraucherschlichtungsstelle teilzunehmen. +

+ +

Haftung für Inhalte

+ +

+ Als Diensteanbieter bin ich nach § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten + verantwortlich. Diese Seite besteht aus einem Hinweis darauf, dass die Anwendung unter + einer anderen Adresse weiterläuft; fremde Inhalte werden hier weder übermittelt noch + gespeichert. +

+ +

Haftung für Links

+ +

+ Diese Seite verlinkt auf kurbeler.de. Für die Inhalte + verlinkter Seiten ist deren Betreiber verantwortlich. Zum Zeitpunkt der Verlinkung waren + keine Rechtsverstöße erkennbar. Wird mir einer bekannt, entferne ich den Link umgehend. +

+ +

Urheberrecht

+ +

+ Das Wappen des Stammtisch Hersbruck und die Texte dieser Seite unterliegen dem deutschen + Urheberrecht. Vervielfältigung und Verbreitung bedürfen der Zustimmung. +

+
diff --git a/frontend/src/routes/invite/[token]/+page.svelte b/frontend/src/routes/invite/[token]/+page.svelte deleted file mode 100644 index fb24233..0000000 --- a/frontend/src/routes/invite/[token]/+page.svelte +++ /dev/null @@ -1,228 +0,0 @@ - - -Einladung - -
- - {#if loading} - - - Einladung wird geprüft … - - {:else if loadError || !team} - - - Einladung ungültig - {loadError} - - -

- Frag im Team nach einem neuen Link — abgelaufene lassen sich nicht - wiederbeleben. -

- -
- {:else} - - {#if logoURL} - - {:else} - - {/if} - Willkommen bei {team.name} - - {#if auth.user} - Du bist als {auth.user.name || auth.user.email} angemeldet. - {:else} - Leg dir ein Konto an — danach stehst du als Fahrer im Kader. - {/if} - - - - - {#if auth.user} - -
- {#if error} -
- {error} -
- {/if} - -

- Nicht du? Mit anderem Konto anmelden -

-
- {:else} -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- - {#if error} -
- {error} -
- {/if} - - - -

- Schon ein Konto? Anmelden und den - Link danach erneut öffnen. -

-
- {/if} -
- {/if} -
-
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte deleted file mode 100644 index d1a5702..0000000 --- a/frontend/src/routes/login/+page.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - -Login - -
- - - - Melde dich an, um fortzufahren - - -
-
- - -
-
- - -
- - - {#if auth.error} -
- {auth.error} -
- {/if} - - -
-
-
-
diff --git a/frontend/src/routes/privacy/+page.svelte b/frontend/src/routes/privacy/+page.svelte new file mode 100644 index 0000000..b734a61 --- /dev/null +++ b/frontend/src/routes/privacy/+page.svelte @@ -0,0 +1,72 @@ + + + +

Verantwortlicher

+ +
+ Daniel Michelberger
+ Am Roten Bühl 11
+ 91247 Vorra
+ hallo@kurbeler.de +
+ +

Worum es geht

+ +

+ Diese Seite besteht aus einem Wappen, einem Satz und einem Link. Sie hat keine Anmeldung, + kein Formular und keine Datenbank. Sie setzt keine Cookies, misst keine Zugriffe und bindet + weder Karten noch Schriften noch Analysedienste von anderen Anbietern ein. +

+ +

Aufruf der Seite

+ +

+ Beim Aufruf überträgt Ihr Browser technisch notwendige Angaben, die der Webserver in einem + Protokoll festhält: IP-Adresse, Zeitpunkt, aufgerufene Adresse, übertragene Datenmenge, + Browser- und Systemangaben. Ohne diese Angaben lässt sich eine Seite nicht ausliefern. + Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; das berechtigte Interesse ist der Betrieb + und die Absicherung der Seite. Die Protokolle werden nicht mit anderen Daten + zusammengeführt und nach spätestens sieben Tagen gelöscht. +

+ +

Hosting

+ +

+ Die Seite läuft bei der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in + einem Rechenzentrum innerhalb der Europäischen Union. Hetzner ist Auftragsverarbeiter nach + Art. 28 DSGVO; ein entsprechender Vertrag besteht. +

+ +

Der Link auf Kurbeler

+ +

+ Ein Klick auf „Zu Kurbeler" führt auf kurbeler.de. Ab + dort gilt die Datenschutzerklärung jener Seite. Von hier wird dabei nichts übermittelt, was + über die Angaben eines gewöhnlichen Seitenaufrufs hinausgeht. +

+ +

Ihre Rechte

+ +

+ Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, + Datenübertragbarkeit und Widerspruch. Wenden Sie sich dafür an die oben genannte Adresse. + Außerdem können Sie sich bei einer Aufsichtsbehörde beschweren, etwa beim Bayerischen + Landesamt für Datenschutzaufsicht, Promenade 27, 91522 Ansbach. +

+
diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts deleted file mode 100644 index 1549c8b..0000000 --- a/frontend/src/service-worker.ts +++ /dev/null @@ -1,125 +0,0 @@ -/// -/// - -import { build, files, version } from '$service-worker' - -/** - * Service Worker für die Zeitnahme im Funkloch. - * - * Ohne ihn ist „offline" nur so lange wahr, wie der Tab offen bleibt: Ein - * Neuladen an der Stage endete in einer weißen Seite, und die Zeiten im - * Ausgang wären mit ihr verschwunden. Eine Stage lässt sich nicht - * wiederholen — deshalb dieser Aufwand. - * - * Drei Sorten Anfragen, drei Strategien: - * - * 1. Was zum Build gehört (JS, CSS, Bilder), liegt unter seiner Versions-URL - * und ändert sich nie. Cache zuerst, ohne Rückfrage. - * 2. Seitenaufrufe kommen vom Server (die App läuft mit SSR). Netz zuerst, - * die Antwort wandert in den Cache; ohne Netz die zuletzt gesehene - * Fassung derselben Seite. - * 3. Lesende Anfragen an PocketBase ebenso — nur so steht nach einem - * Neuladen im Funkloch überhaupt ein Kader da, gegen den sich stoppen - * lässt. Schreibende Anfragen werden nie aus dem Cache beantwortet: Ein - * Start, der aus dem Cache „gelingt", wäre eine Zeit, die es nicht gibt. - * Dafür ist der Ausgang in timeOutbox zuständig. - */ - -const sw = self as unknown as ServiceWorkerGlobalScope - -const ASSETS = `assets-${version}` -const PAGES = `pages-${version}` -const API = `api-${version}` - -const PRECACHE = [...build, ...files] - -sw.addEventListener('install', (event) => { - event.waitUntil( - caches - .open(ASSETS) - .then((cache) => cache.addAll(PRECACHE)) - .then(() => sw.skipWaiting()), - ) -}) - -sw.addEventListener('activate', (event) => { - // Alte Versionen wegräumen. Ein Gerät an der Strecke soll nicht mit dem - // Vorrat von vor drei Events dastehen. - event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all( - keys - .filter((k) => k !== ASSETS && k !== PAGES && k !== API) - .map((k) => caches.delete(k)), - ), - ) - .then(() => sw.clients.claim()), - ) -}) - -/** - * Beim Abmelden fliegt der Vorrat an API-Antworten raus. Sonst stünden die - * Daten des vorigen Nutzers offline noch da — auf einem geteilten Gerät ein - * Einblick, den niemand gegeben hat. - */ -sw.addEventListener('message', (event) => { - if (event.data === 'clear-api-cache') { - event.waitUntil(caches.delete(API)) - } -}) - -async function cacheFirst(request: Request): Promise { - const cached = await caches.match(request) - if (cached) return cached - return fetch(request) -} - -/** Netz zuerst, Cache als Auffangnetz — und jede geglückte Antwort merken. */ -async function networkFirst(request: Request, cacheName: string): Promise { - const cache = await caches.open(cacheName) - - try { - const response = await fetch(request) - // Nur Vollstaendiges merken: Ein 206 oder ein Fehler waere im - // Funkloch schlimmer als gar nichts. - if (response.ok && response.status === 200) { - cache.put(request, response.clone()) - } - return response - } catch (err) { - const cached = await cache.match(request) - if (cached) return cached - throw err - } -} - -sw.addEventListener('fetch', (event) => { - const { request } = event - if (request.method !== 'GET') return - - const url = new URL(request.url) - - // Andere Ursprünge (Kartenkacheln, Schriften) gehen den normalen Weg. - // Kacheln zu horten waere ein Vorrat ohne Ende. - const sameOrigin = url.origin === sw.location.origin - const isApi = url.pathname.startsWith('/api/') - - if (sameOrigin && PRECACHE.includes(url.pathname)) { - event.respondWith(cacheFirst(request)) - return - } - - if (isApi) { - // Der Uhrenabgleich darf nie aus dem Cache kommen — eine gemerkte - // Uhrzeit ist das Gegenteil von dem, wozu er da ist. - if (url.pathname === '/api/clock') return - event.respondWith(networkFirst(request, API)) - return - } - - if (sameOrigin && request.mode === 'navigate') { - event.respondWith(networkFirst(request, PAGES)) - } -}) diff --git a/frontend/static/manifest.webmanifest b/frontend/static/manifest.webmanifest deleted file mode 100644 index e52a07f..0000000 --- a/frontend/static/manifest.webmanifest +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "Stammtisch Hersbruck", - "short_name": "Stammtisch", - "description": "Events, Stages, Zeiten und Trails des Stammtisch Hersbruck", - "start_url": "/dashboard", - "scope": "/", - "display": "standalone", - "orientation": "portrait-primary", - "background_color": "#ffffff", - "theme_color": "#eab308", - "lang": "de", - "icons": [ - { - "src": "/schildl.svg", - "sizes": "any", - "type": "image/svg+xml", - "purpose": "any" - } - ] -} diff --git a/frontend/static/pedaler.svg b/frontend/static/pedaler.svg deleted file mode 100644 index 8e9c4e4..0000000 --- a/frontend/static/pedaler.svg +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/static/radl.svg b/frontend/static/radl.svg deleted file mode 100644 index 6d59d70..0000000 --- a/frontend/static/radl.svg +++ /dev/null @@ -1,942 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/static/schildl.svg b/frontend/static/schildl.svg deleted file mode 100644 index effdd9f..0000000 --- a/frontend/static/schildl.svg +++ /dev/null @@ -1,896 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/static/stammtisch-hersbruck.svg b/frontend/static/stammtisch-hersbruck.svg new file mode 100644 index 0000000..0f62193 --- /dev/null +++ b/frontend/static/stammtisch-hersbruck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index ad652c2..9468769 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -1,22 +1,30 @@ -import adapter from '@sveltejs/adapter-node'; +import adapter from '@sveltejs/adapter-static'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { - // Consult https://svelte.dev/docs/kit/integrations - // for more information about preprocessors - preprocess: vitePreprocess(), + preprocess: vitePreprocess(), - kit: { - // adapter-node statt adapter-auto: Das Deployment läuft über Coolify, - // das adapter-auto nicht erkennt ("Could not detect a supported - // production environment") — es entstand dann gar kein build/, und der - // Startbefehl "node build" scheiterte sofort. - adapter: adapter(), + kit: { + // `adapter-static` statt `adapter-node`: Übrig ist ein Wegweiser aus + // drei Seiten ohne eine einzige dynamische Angabe. Dafür einen + // Node-Prozess laufen zu lassen, der auf Anfragen wartet, um jedes + // Mal dasselbe HTML zu erzeugen, wäre ein Serverprozess als + // Dauerbeschäftigung ohne Arbeit. + // + // `fallback` bleibt aus: Es gibt keine Route, die nicht beim Bauen + // bekannt wäre. Was es nicht gibt, soll 404 sein und nicht die + // Startseite mit einer leeren Mitte. + adapter: adapter({ + pages: 'build', + assets: 'build', + precompress: true, + strict: true, + }), alias: { - "@/*": "./src/lib/*", + '@/*': './src/lib/*', }, - } + }, }; export default config;