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} - - {/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;