Compare commits

..

No commits in common. "1257456308733d73f9cb8ec875e7ff44a1f2593f" and "f3160583677e711998681207683fa9d42cbbcd1f" have entirely different histories.

25 changed files with 40 additions and 8231 deletions

1
.gitignore vendored
View file

@ -33,4 +33,3 @@ backups/
# IDE
.idea/
/frontend/TODO.md

File diff suppressed because it is too large Load diff

View file

@ -1,271 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Trails: GPX-Tracks mit Versionen, verorteten Markern und Kommentaren.
//
// Reihenfolge ist wichtig: trails.current zeigt auf trail_versions,
// trail_versions.trail zeigt zurück auf trails. Deshalb wird trails zuerst
// OHNE current angelegt, dann trail_versions, und current danach ergänzt.
migrate((app) => {
const teams = app.findCollectionByNameOrId('teams')
const users = app.findCollectionByNameOrId('users')
// Für jede Collection gleich: Standardfelder, die PocketBase nicht
// selbst anlegt.
const timestamps = [
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
]
const teamScoped = (name) => ({
name,
type: 'relation',
required: true,
maxSelect: 1,
collectionId: teams.id,
cascadeDelete: true,
})
// --- trails (noch ohne current) --------------------------------------
const trails = new Collection({
type: 'base',
name: 'trails',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
// Bearbeiten dürfen Paten und Team-Admins
updateRule:
'stewards.id ?= @request.auth.id' +
' || team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id',
// Löschen nur Admins — am Trail hängen fremde Marker und Kommentare
deleteRule:
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id',
fields: [
teamScoped('team'),
{ name: 'name', type: 'text', required: true, max: 200 },
{ name: 'description', type: 'editor' },
{
name: 'status',
type: 'select',
maxSelect: 1,
values: ['offen', 'eingeschraenkt', 'gesperrt'],
},
{
name: 'stewards',
type: 'relation',
maxSelect: 0,
collectionId: users.id,
cascadeDelete: false,
},
{
name: 'event',
type: 'relation',
maxSelect: 1,
collectionId: app.findCollectionByNameOrId('events').id,
cascadeDelete: false,
},
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(trails)
// --- trail_versions ---------------------------------------------------
const versions = new Collection({
type: 'base',
name: 'trail_versions',
listRule: 'trail.team.users.id ?= @request.auth.id',
viewRule: 'trail.team.users.id ?= @request.auth.id',
createRule:
'trail.stewards.id ?= @request.auth.id' +
' || trail.team.owner.id ?= @request.auth.id' +
' || trail.team.admins.id ?= @request.auth.id',
// Versionen sind unveränderlich — eine Korrektur ist eine neue Version
updateRule: null,
deleteRule:
'trail.team.owner.id ?= @request.auth.id' +
' || trail.team.admins.id ?= @request.auth.id',
fields: [
{
name: 'trail',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: trails.id,
cascadeDelete: true,
},
{
name: 'gpx',
type: 'file',
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ['application/gpx+xml', 'application/xml', 'text/xml'],
},
{ name: 'geojson', type: 'json', maxSize: 5000000 },
{ name: 'elevation', type: 'json', maxSize: 2000000 },
{ name: 'bounds', type: 'json', maxSize: 1000 },
{ name: 'distance_m', type: 'number' },
{ name: 'ascent_m', type: 'number' },
{ name: 'note', type: 'text', max: 500 },
{
name: 'uploaded_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(versions)
// --- trails.current nachtragen ---------------------------------------
trails.fields.add(new Field({
name: 'current',
type: 'relation',
maxSelect: 1,
collectionId: versions.id,
cascadeDelete: false,
}))
app.save(trails)
// --- trail_flags ------------------------------------------------------
const adminOnly =
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
const flags = new Collection({
type: 'base',
name: 'trail_flags',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: adminOnly,
updateRule: adminOnly,
// Löschen nur, solange kein Marker diesen Typ verwendet — sonst
// bliebe ein Pflichtfeld zurück, das ins Leere zeigt.
// Klammern nötig: && bindet stärker als || — ohne sie gälte die
// Marker-Prüfung nur für den admins-Zweig, nicht für owner.
deleteRule: '(' + adminOnly + ') && @collection.trail_markers.flag ?!= id',
fields: [
teamScoped('team'),
{ name: 'label', type: 'text', required: true, max: 60 },
{ name: 'icon', type: 'text', max: 40 },
{ name: 'color', type: 'text', max: 9 },
{
name: 'severity',
type: 'select',
maxSelect: 1,
values: ['info', 'warnung', 'kritisch'],
},
...timestamps,
],
})
app.save(flags)
// --- trail_markers ----------------------------------------------------
// Anlegen darf jedes Team-Mitglied; ändern nur der Ersteller, Paten
// oder Admins.
const ownerOrSteward =
'created_by.id ?= @request.auth.id' +
' || trail.stewards.id ?= @request.auth.id' +
' || team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
const markers = new Collection({
type: 'base',
name: 'trail_markers',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: ownerOrSteward,
deleteRule: ownerOrSteward,
fields: [
{
name: 'trail',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: trails.id,
cascadeDelete: true,
},
teamScoped('team'),
{
name: 'flag',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: flags.id,
cascadeDelete: false,
},
{ name: 'lat', type: 'number', required: true },
{ name: 'lng', type: 'number', required: true },
{ name: 'note', type: 'text', max: 500 },
{ name: 'resolved', type: 'bool' },
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(markers)
// --- trail_comments ---------------------------------------------------
const comments = new Collection({
type: 'base',
name: 'trail_comments',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: ownerOrSteward,
deleteRule: ownerOrSteward,
fields: [
{
name: 'trail',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: trails.id,
cascadeDelete: true,
},
teamScoped('team'),
{ name: 'text', type: 'text', required: true, max: 2000 },
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(comments)
}, (app) => {
// Rückwärts in umgekehrter Reihenfolge, damit keine Relation ins Leere
// zeigt. trails zuletzt, weil alle anderen darauf verweisen.
for (const name of [
'trail_comments',
'trail_markers',
'trail_flags',
'trail_versions',
'trails',
]) {
try {
app.delete(app.findCollectionByNameOrId(name))
} catch {
// Bereits entfernt — nichts zu tun.
}
}
})

View file

@ -1,17 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Das Avatar-Feld stand auf maxSize 0 — also unbegrenzt. Das ist ein
// Ressourcenrisiko: Ein einzelner Upload könnte beliebig groß sein.
// Zurück auf die ursprünglichen 5 MB.
migrate((app) => {
const users = app.findCollectionByNameOrId('users')
const avatar = users.fields.getByName('avatar')
avatar.maxSize = 5242880
app.save(users)
}, (app) => {
const users = app.findCollectionByNameOrId('users')
const avatar = users.fields.getByName('avatar')
avatar.maxSize = 0
app.save(users)
})

View file

@ -1,26 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// trail_flags.deleteRule ergänzt: Löschen nur, solange kein Marker diesen
// Typ verwendet — sonst bliebe ein Pflichtfeld (trail_markers.flag) zurück,
// das ins Leere zeigt. Separate Migration, weil 1754500000_created_trails.js
// bereits angewendet wurde und PocketBase Migrationsdateien nur einmal
// anhand des Dateinamens ausführt, unabhängig vom Inhalt.
//
// Klammern nötig: && bindet stärker als || — ohne sie gälte die
// Marker-Prüfung nur für den admins-Zweig, nicht für owner.
migrate((app) => {
const flags = app.findCollectionByNameOrId('trail_flags')
const adminOnly =
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
flags.deleteRule = '(' + adminOnly + ') && @collection.trail_markers.flag ?!= id'
app.save(flags)
}, (app) => {
const flags = app.findCollectionByNameOrId('trail_flags')
const adminOnly =
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
flags.deleteRule = adminOnly
app.save(flags)
})

View file

@ -1,24 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Präzedenzfehler in trail_flags.deleteRule behoben: && bindet stärker als
// ||, ohne Klammern galt die Marker-Prüfung nur für den admins-Zweig.
// Ein Team-Owner konnte damit einen noch referenzierten Flag-Typ löschen
// und verwaiste Marker hinterlassen. Separate Migration, weil
// 1754500200_trail_flags_delete_rule.js bereits mit der fehlerhaften Regel
// angewendet wurde.
migrate((app) => {
const flags = app.findCollectionByNameOrId('trail_flags')
const adminOnly =
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
flags.deleteRule = '(' + adminOnly + ') && @collection.trail_markers.flag ?!= id'
app.save(flags)
}, (app) => {
const flags = app.findCollectionByNameOrId('trail_flags')
const adminOnly =
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
flags.deleteRule = adminOnly + ' && @collection.trail_markers.flag ?!= id'
app.save(flags)
})

View file

@ -1,20 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Kumulative Distanz je Koordinate der vereinfachten Geometrie. Nötig, weil
// elevation einen Eintrag pro Originalpunkt hat, geojson aber nur die
// vereinfachten — ohne diese Brücke zeigt die Hervorhebung aus dem
// Höhenprofil auf der Karte an die falsche Stelle.
migrate((app) => {
const c = app.findCollectionByNameOrId('trail_versions')
c.fields.add(new Field({
name: 'coord_distances',
type: 'json',
maxSize: 2000000,
}))
app.save(c)
}, (app) => {
const c = app.findCollectionByNameOrId('trail_versions')
c.fields.removeByName('coord_distances')
app.save(c)
})

File diff suppressed because it is too large Load diff

View file

@ -1,318 +0,0 @@
# Trails: GPX-Tracks, Marker, Flags und Kommentare
**Datum:** 2026-08-06
**Status:** Entwurf, vom Nutzer freigegeben
## Ziel
Teams sollen GPX-Tracks hochladen, beschriften und auf einer Karte ansehen
können. Zu jedem Trail lassen sich verortete Meldungen setzen (Baum quer,
Erosion, gesperrt) und Kommentare schreiben. Neue GPX-Uploads ersetzen den
Track nicht, sondern legen eine Version an — Korrekturen bleiben
nachvollziehbar.
## Ausgangslage
- SvelteKit 5 mit Svelte-5-Runes, PocketBase als Backend (Monorepo:
`frontend/`, `backend/`)
- Bestehende Collections: `users`, `teams`, `events`, `runs`, `riders`, `times`
- Alles ist teamgebunden: Die API-Rules lauten durchweg
`team.users.id ?= @request.auth.id`
- Store-Muster: Klasse mit `$state`, Context über Symbol, Realtime-Subscription,
`scoped`-Getter für das aktive Team (siehe `src/lib/stores/events.svelte.ts`)
- **Keine Kartenbibliothek vorhanden** — MapLibre GL kommt neu dazu
- Das Schema ist als Migration in `backend/pb_migrations/` versioniert; Typen
entstehen daraus über `npm run generate-pocketbase-types`
## Entscheidungen
| Frage | Entscheidung |
|---|---|
| Zweck | Eigenständiger Trail-Katalog, optional einem Event zugeordnet |
| Sichtbarkeit | Teamspezifisch, keine öffentlichen Trails |
| Bearbeiten | Trail-Paten (`stewards`) und Team-Admins |
| Flags setzen | Jedes Team-Mitglied — schnelle Information hat Vorrang |
| Flag-Typen | Eigene Collection, im UI pflegbar |
| Flag-Bezug | Trail-Gesamtstatus **und** verortete Marker |
| GPX-Speicherung | Originaldatei **und** extrahierte Geometrie |
| Parsing | Im Browser beim Upload — kein `pb_hooks` nötig |
| Versionierung | Trail = Identität, Versionen als eigene Collection |
| Marker/Kommentare | Hängen am Trail, nicht an der Version |
| Karte | MapLibre GL mit OSM-Raster |
| Höhenprofil | Ja, verknüpft mit der Karte |
## Datenmodell
Fünf neue Collections. Alle tragen `team` und die Standardfelder `created`
und `updated` als `autodate`.
### `trails`
Die dauerhafte Identität eines Trails.
| Feld | Typ | Anmerkung |
|---|---|---|
| `team` | relation → teams | Pflicht, single |
| `name` | text | Pflicht |
| `description` | editor | Rich-Text |
| `status` | select | `offen`, `gesperrt`, `eingeschraenkt` |
| `stewards` | relation → users | mehrfach, die Trail-Paten |
| `event` | relation → events | optional, single |
| `current` | relation → trail_versions | die aktive Version, single |
| `created_by` | relation → users | single |
`current` erzeugt einen Zirkelbezug zu `trail_versions.trail`. PocketBase
verträgt das, die Migration muss die Collections aber in zwei Schritten
anlegen (siehe „Migration" unten).
**Warum nicht ein `active`-Flag auf `trail_versions`?** Das wäre ohne
Zirkelbezug ausgekommen, verlagert die Wahrheit aber in N Datensätze statt
einen: Beim Umschalten müssten zwei Versionen geschrieben werden, und ohne
Transaktion kann es kurzzeitig zwei aktive oder gar keine geben. Mit
`trails.current` ändert sich genau ein Feld, und ein ungültiger Zustand ist
nicht möglich. Der Zirkelbezug kostet dafür einen zusätzlichen
Migrationsschritt — einmalig, gegenüber einer Fehlerquelle im Betrieb.
Dass `current` theoretisch auf eine Version eines anderen Trails zeigen
könnte, fängt das Frontend ab: `activate(versionId)` prüft vorher, dass die
Version zum Trail gehört.
### `trail_versions`
Jeder GPX-Upload erzeugt einen Datensatz. Sie werden nie überschrieben.
| Feld | Typ | Anmerkung |
|---|---|---|
| `trail` | relation → trails | Pflicht, single |
| `gpx` | file | das Original, herunterladbar, max. 10 MB |
| `geojson` | json | LineString für die Karte, vereinfacht |
| `distance_m` | number | Streckenlänge in Metern |
| `ascent_m` | number | Höhenmeter aufwärts |
| `elevation` | json | Array `[{d, ele}]` für das Höhenprofil |
| `bounds` | json | `[[minLng, minLat], [maxLng, maxLat]]` |
| `note` | text | was an dieser Version anders ist |
| `uploaded_by` | relation → users | single |
### `trail_flags`
Die Flag-Typen, pro Team pflegbar.
| Feld | Typ | Anmerkung |
|---|---|---|
| `team` | relation → teams | Pflicht |
| `label` | text | Pflicht, z. B. „Baum quer" |
| `icon` | text | Name eines Lucide-Icons |
| `color` | text | Hex-Farbe, z. B. `#dc2626` |
| `severity` | select | `info`, `warnung`, `kritisch` |
Beim Anlegen eines Teams gibt es **keine** automatischen Standard-Flags; die
Flag-Verwaltung bietet stattdessen einen Knopf „Standardsatz anlegen", der
sechs übliche Typen erzeugt (Baum quer, Erosion, Verblockt, Sperrung,
Bauarbeiten, Hinweis). Das hält die Migration frei von Seed-Daten.
### `trail_markers`
Verortete Meldungen. Sie hängen am Trail, nicht an einer Version — ein
umgestürzter Baum liegt im Gelände, nicht in einer Datei.
| Feld | Typ | Anmerkung |
|---|---|---|
| `trail` | relation → trails | Pflicht |
| `team` | relation → teams | Pflicht, für die API-Rules |
| `flag` | relation → trail_flags | Pflicht |
| `lat` | number | Pflicht |
| `lng` | number | Pflicht |
| `note` | text | freie Beschreibung |
| `resolved` | bool | erledigt statt gelöscht |
| `created_by` | relation → users | single |
### `trail_comments`
| Feld | Typ | Anmerkung |
|---|---|---|
| `trail` | relation → trails | Pflicht |
| `team` | relation → teams | Pflicht, für die API-Rules |
| `text` | text | Pflicht |
| `created_by` | relation → users | single |
`team` wird auf `markers` und `comments` mitgeführt, obwohl es über
`trail.team` erreichbar wäre. Grund: Die API-Rule bleibt damit eine direkte
Prüfung statt einer Relation über zwei Ebenen — das ist schneller und
robuster gegen Tippfehler in der Regel.
## API-Rules
**`trails`**
```
listRule team.users.id ?= @request.auth.id
viewRule team.users.id ?= @request.auth.id
createRule team.users.id ?= @request.auth.id
updateRule stewards.id ?= @request.auth.id
|| team.owner.id ?= @request.auth.id
|| team.admins.id ?= @request.auth.id
deleteRule team.owner.id ?= @request.auth.id
|| team.admins.id ?= @request.auth.id
```
Anlegen darf jedes Team-Mitglied; wer anlegt, wird im Frontend automatisch als
erster Pate eingetragen. Löschen ist Admins vorbehalten — ein Trail trägt
Kommentare und Marker anderer Leute.
**`trail_versions`**
```
listRule/viewRule trail.team.users.id ?= @request.auth.id
createRule trail.stewards.id ?= @request.auth.id
|| trail.team.owner.id ?= @request.auth.id
|| trail.team.admins.id ?= @request.auth.id
updateRule null (Versionen sind unveränderlich)
deleteRule trail.team.owner.id ?= @request.auth.id
|| trail.team.admins.id ?= @request.auth.id
```
**`trail_flags`**
```
listRule/viewRule team.users.id ?= @request.auth.id
createRule/updateRule/deleteRule team.owner.id ?= @request.auth.id
|| team.admins.id ?= @request.auth.id
```
**`trail_markers`** und **`trail_comments`**
```
listRule/viewRule team.users.id ?= @request.auth.id
createRule team.users.id ?= @request.auth.id
updateRule created_by.id ?= @request.auth.id
|| trail.stewards.id ?= @request.auth.id
|| team.owner.id ?= @request.auth.id
|| team.admins.id ?= @request.auth.id
deleteRule (wie updateRule)
```
## GPX-Verarbeitung
Das Parsen läuft im Browser (`src/lib/gpx.ts`), das Backend bleibt reines
PocketBase ohne Hooks.
**Ablauf beim Upload:**
1. Datei über `DOMParser` als XML lesen
2. Alle `<trkpt lat lng>` mit optionalem `<ele>` einsammeln
3. Streckenlänge über die Haversine-Formel aufsummieren
4. Höhenmeter aufwärts summieren, dabei Schwankungen unter 3 m ignorieren
(GPS-Rauschen würde sonst absurde Werte erzeugen)
5. Bounding-Box bestimmen
6. Punktfolge für die Anzeige vereinfachen (Douglas-Peucker, Toleranz so
gewählt, dass höchstens ~2000 Punkte übrig bleiben)
7. Original-Datei und abgeleitete Werte gemeinsam an PocketBase schicken
**Fehlerbehandlung:** Enthält die Datei keine `<trkpt>`-Elemente oder ist sie
kein gültiges XML, wird der Upload mit einer verständlichen Meldung
abgelehnt. Dateien über 10 MB werden vorab abgewiesen.
**Routen statt Tracks:** Manche GPX-Dateien enthalten `<rtept>` statt
`<trkpt>`. Fehlen Trackpunkte, greift der Parser auf Routenpunkte zurück,
bevor er aufgibt.
## Karte
MapLibre GL JS mit OSM-Raster-Kacheln (`tile.openstreetmap.org`), kein
API-Schlüssel nötig. Die Kachel-URL steht in einer Konstanten, damit sie
später gegen eine Outdoor-Karte getauscht werden kann.
Die Karte lebt in `src/lib/components/TrailMap.svelte` mit den Props:
`geojson`, `markers`, `status`, `interactive`. Sie kennt weder Stores noch
PocketBase — Daten kommen ausschließlich über Props, Ereignisse gehen über
Callback-Props zurück. Damit ist sie in Liste, Detail und Marker-Setzmodus
gleichermaßen verwendbar.
Die Trail-Linie wird nach `status` eingefärbt: offen grün, eingeschränkt
gelb, gesperrt rot. Marker tragen Icon und Farbe ihres Flag-Typs; erledigte
Marker (`resolved`) erscheinen blass und lassen sich ausblenden.
**Marker setzen:** Ein Umschalter versetzt die Karte in den Setzmodus; der
nächste Klick öffnet einen Dialog mit Flag-Auswahl und Notiz.
## Höhenprofil
`src/lib/components/ElevationProfile.svelte` zeichnet die Werte aus
`trail_versions.elevation` als SVG-Fläche — keine Diagramm-Bibliothek, die
Daten sind eine einfache Zahlenreihe.
Beim Überfahren wird die entsprechende Stelle auf der Karte hervorgehoben.
Die Verknüpfung läuft über einen Index in der Punktfolge, den beide
Komponenten teilen; die Elternkomponente hält ihn als `$state`.
## Stores
Vier neue Stores nach dem Muster von `events.svelte.ts` — Klasse mit
`$state`, Context über Symbol, `scoped`-Getter, Realtime-Subscription:
- `trails.svelte.ts` — Trails samt `create`, `edit`, `remove`
- `trailVersions.svelte.ts` — Versionen eines Trails, `upload`, `activate`
- `trailFlags.svelte.ts` — Flag-Typen, `seedDefaults()`
- `trailMarkers.svelte.ts` — Marker, `toggleResolved`
Kommentare bekommen **keinen** eigenen Store, sondern werden in der
Detailseite direkt geladen — sie werden nur an einer Stelle gebraucht.
## Routen
| Route | Inhalt |
|---|---|
| `/dashboard/trails` | Liste mit Übersichtskarte aller Trails des Teams |
| `/dashboard/trails/[id]` | Karte, Höhenprofil, Marker, Kommentare, Versionen |
| `/dashboard/settings/flags` | Flag-Typen verwalten |
Die Trail-Detailseite gliedert sich in Karte (mit Höhenprofil darunter) und
einen Bereich mit Reitern für Marker, Kommentare und Versionen.
## Migration
Eine neue Datei in `backend/pb_migrations/`. Zwei Besonderheiten:
1. **Zirkelbezug:** `trails.current` zeigt auf `trail_versions`, das seinerseits
auf `trails` zeigt. Die Migration legt deshalb zuerst `trails` ohne das
Feld `current` an, dann `trail_versions`, und ergänzt `current` in einem
zweiten Schritt.
2. **Keine Seed-Daten.** Standard-Flags entstehen auf Knopfdruck im UI, nicht
in der Migration — sonst kämen sie bei jedem Containerstart zurück.
Nach der Migration ist `npm run generate-pocketbase-types` auszuführen und
`frontend/src/lib/types.d.ts` mitzucommitten.
## Neue Abhängigkeiten
| Paket | Zweck |
|---|---|
| `maplibre-gl` | Kartendarstellung |
Mehr nicht: GPX-Parsing, Douglas-Peucker und das Höhenprofil sind wenige
Dutzend Zeilen und kommen ohne Bibliothek aus.
## Verifikation
1. Migration im lokalen Container: alle fünf Collections vorhanden, Felder und
Rules wie beschrieben
2. Eine echte GPX-Datei hochladen: Länge und Höhenmeter plausibel gegenüber
dem Wert, den ein anderes Werkzeug (z. B. gpx.studio) nennt
3. Track erscheint auf der Karte, Kartenausschnitt passt zur Bounding-Box
4. Höhenprofil zeigt einen Verlauf, Hover markiert die Stelle auf der Karte
5. Marker setzen, Flag zuweisen, als erledigt markieren
6. Kommentar schreiben, erscheint bei einem zweiten Browser per Realtime
7. Zweite GPX-Version hochladen: Version wird angelegt, Marker und Kommentare
bleiben erhalten
8. Rechte: Ein Team-Mitglied ohne Patenschaft kann Marker und Kommentare
anlegen, aber den Trail nicht umbenennen
9. `npm run check` ohne neue Fehler
## Bewusst nicht Teil dieser Umsetzung
- Öffentliche, nicht angemeldete Trail-Ansicht
- Bestätigungs-Workflow für Meldungen (jeder im Team meldet direkt)
- Automatisches Aufräumen alter Versionen
- Ein Trail in mehreren Events gleichzeitig
- Offline-Karten, Track-Aufzeichnung im Browser
- Verknüpfung der Trails mit der Zeitnahme (`runs`, `times`)

File diff suppressed because it is too large Load diff

View file

@ -13,8 +13,6 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"generate-pocketbase-types": "node scripts/schema-to-json.mjs > .schema.tmp.json && pocketbase-typegen --json .schema.tmp.json --out ./src/lib/types.d.ts && rm -f .schema.tmp.json"
},
"devDependencies": {
@ -28,7 +26,6 @@
"@tailwindcss/vite": "^4.1.13",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"jsdom": "^30.0.1",
"pocketbase-typegen": "^1.3.1",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
@ -37,14 +34,11 @@
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.2",
"vite": "^7.1.7",
"vitest": "^4.1.10"
"vite": "^7.1.7"
},
"dependencies": {
"date-fns": "^4.1.0",
"dompurify": "^3.4.13",
"lucide-svelte": "^0.503.0",
"maplibre-gl": "^6.2.0",
"mode-watcher": "^1.1.0",
"pocketbase": "^0.26.0",
"tippy.js": "^6.3.7"

View file

@ -1,93 +0,0 @@
<script lang="ts">
/**
* Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente die
* Distanz des Punktes; die Elternkomponente reicht sie an die Karte
* weiter, damit dort dieselbe Stelle hervorgehoben wird. Distanz statt
* Index, weil die Karte eine andere (vereinfachte) Punktreihe zeigt.
*/
let {
elevation,
onhover,
height = '120px',
}: {
elevation: { d: number; ele: number }[]
onhover?: (distance: number | null) => void
height?: string
} = $props()
// Internes Koordinatensystem; das SVG skaliert über viewBox mit.
const W = 1000
const H = 200
const stats = $derived.by(() => {
if (elevation.length < 2) return null
const eles = elevation.map((p) => p.ele)
const min = Math.min(...eles)
const max = Math.max(...eles)
const totalD = elevation[elevation.length - 1].d || 1
// Bei flachen Strecken wäre die Spanne 0 und die Division ungültig.
const span = max - min || 1
return { min, max, totalD, span }
})
const path = $derived.by(() => {
if (!stats) return ''
const { min, totalD, span } = stats
const pts = elevation.map((p) => {
const x = (p.d / totalD) * W
const y = H - ((p.ele - min) / span) * H
return `${x.toFixed(1)},${y.toFixed(1)}`
})
// Fläche schließen: runter zur Grundlinie und zurück zum Anfang
return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z`
})
let hoverX = $state<number | null>(null)
function handleMove(e: MouseEvent) {
if (!elevation.length) return
const rect = (e.currentTarget as SVGElement).getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
const idx = Math.round(ratio * (elevation.length - 1))
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
hoverX = ratio * W
onhover?.(elevation[clamped].d)
}
function handleLeave() {
hoverX = null
onhover?.(null)
}
</script>
{#if stats}
<div class="w-full" style:height>
<svg
viewBox="0 0 {W} {H}"
preserveAspectRatio="none"
class="w-full h-full"
role="img"
aria-label="Höhenprofil"
onmousemove={handleMove}
onmouseleave={handleLeave}
>
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
{#if hoverX !== null}
<line x1={hoverX} y1="0" x2={hoverX} y2={H} stroke="rgb(14 165 233)" stroke-width="1" vector-effect="non-scaling-stroke" />
{/if}
</svg>
</div>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>{Math.round(stats.min)} m</span>
<span>{(stats.totalD / 1000).toFixed(1)} km</span>
<span>{Math.round(stats.max)} m</span>
</div>
{:else}
<p class="text-sm text-muted-foreground">Keine Höhendaten vorhanden.</p>
{/if}

View file

@ -1,259 +0,0 @@
<script lang="ts">
/**
* Karte für einen Trail. Bewusst ohne Store-Zugriff: Alles kommt über
* Props, Ereignisse gehen über Callback-Props zurück. Damit ist die
* Komponente in Liste, Detailseite und Setzmodus gleichermaßen nutzbar.
*/
import { onMount } from 'svelte'
import * as maplibregl from 'maplibre-gl'
import type { Map as MapLibreMap, Marker } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
type MarkerData = {
id: string
lat: number
lng: number
color: string
resolved: boolean
label: string
}
let {
geojson = null,
bounds = null,
markers = [],
status = 'offen',
placing = false,
coordDistances = [],
highlightDistance = null,
onplace,
onmarkerclick,
height = '420px',
}: {
geojson?: LineGeoJSON | null
bounds?: [[number, number], [number, number]] | null
markers?: MarkerData[]
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
placing?: boolean
coordDistances?: number[]
highlightDistance?: number | null
onplace?: (coords: { lat: number; lng: number }) => void
onmarkerclick?: (id: string) => void
height?: string
} = $props()
// Kachelquelle als Konstante, damit sie sich später leicht gegen eine
// Outdoor-Karte mit Höhenlinien tauschen lässt.
const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende'
// Fallback, wenn ein Trail noch keine Version hat: Hersbruck.
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
const LINE_COLORS = {
offen: '#16a34a',
eingeschraenkt: '#ca8a04',
gesperrt: '#dc2626',
} as const
let container: HTMLDivElement
let map = $state<MapLibreMap | null>(null)
let ready = $state(false)
let markerObjects: Marker[] = []
let highlightMarker: Marker | null = null
onMount(() => {
let cleanupMap: (() => void) | null = null
function build() {
const instance = new maplibregl.Map({
container,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: [TILES],
tileSize: 256,
attribution: ATTRIBUTION,
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
},
center: FALLBACK_CENTER,
zoom: 11,
})
map = instance
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
instance.on('load', () => {
ready = true
})
instance.on('click', (e) => {
if (placing) onplace?.({ lat: e.lngLat.lat, lng: e.lngLat.lng })
})
cleanupMap = () => {
// Map.remove() räumt Controls auf, aber keine Marker — die tragen
// eigene move-Listener auf der Karte und werden hier zuerst entfernt.
for (const m of markerObjects) m.remove()
markerObjects = []
highlightMarker?.remove()
highlightMarker = null
instance.remove()
map = null
ready = false
}
}
// Browser begrenzen WebGL-Kontexte auf rund 8 bis 16. In einer Liste
// mit vielen Trails wären das zu viele — deshalb entsteht die Karte
// erst, wenn ihre Kachel in den Sichtbereich scrollt.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !map) build()
}
},
{ rootMargin: '200px' },
)
observer.observe(container)
return () => {
observer.disconnect()
cleanupMap?.()
}
})
// Trail-Linie zeichnen bzw. aktualisieren
$effect(() => {
if (!map || !ready) return
const data = geojson
const color = LINE_COLORS[status] ?? LINE_COLORS.offen
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
if (!data) {
if (src) {
src.setData({ type: 'FeatureCollection', features: [] })
}
return
}
const feature = { type: 'Feature' as const, properties: {}, geometry: data }
if (src) {
src.setData(feature)
map.setPaintProperty('trail-line', 'line-color', color)
} else {
map.addSource('trail', { type: 'geojson', data: feature })
map.addLayer({
id: 'trail-line',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 4 },
})
}
})
// Kartenausschnitt auf den Track setzen
$effect(() => {
if (!map || !ready || !bounds) return
map.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 16 })
})
// Marker neu aufbauen — bei überschaubaren Mengen einfacher und
// zuverlässiger als einzelne Aktualisierungen.
$effect(() => {
if (!map || !ready) return
for (const m of markerObjects) m.remove()
markerObjects = []
for (const data of markers) {
const el = document.createElement('button')
el.type = 'button'
el.title = data.label
el.setAttribute('aria-label', data.label)
el.style.cssText = `
width: 18px; height: 18px; border-radius: 9999px;
border: 2px solid white; cursor: pointer; padding: 0;
background: ${data.color};
opacity: ${data.resolved ? '0.35' : '1'};
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
el.addEventListener('click', (ev) => {
ev.stopPropagation()
onmarkerclick?.(data.id)
})
el.addEventListener('focus', () => {
el.style.outline = '2px solid #0ea5e9'
el.style.outlineOffset = '2px'
})
el.addEventListener('blur', () => {
el.style.outline = 'none'
})
markerObjects.push(
new maplibregl.Marker({ element: el })
.setLngLat([data.lng, data.lat])
.addTo(map),
)
}
})
// Stelle aus dem Höhenprofil hervorheben. Die Kopplung läuft über die
// Distanz, nicht über den Index: elevation hat einen Eintrag pro
// Originalpunkt, coordinates nur die vereinfachten.
$effect(() => {
if (!map || !ready) return
highlightMarker?.remove()
highlightMarker = null
const coords = geojson?.coordinates
if (highlightDistance == null || !coords?.length || !coordDistances.length) return
// Nächstgelegene Koordinate zur gesuchten Distanz
let best = 0
let bestDiff = Infinity
for (let i = 0; i < coordDistances.length; i++) {
const diff = Math.abs(coordDistances[i] - highlightDistance)
if (diff < bestDiff) {
bestDiff = diff
best = i
}
}
const idx = Math.min(coords.length - 1, best)
const el = document.createElement('div')
el.style.cssText = `
width: 12px; height: 12px; border-radius: 9999px;
background: #0ea5e9; border: 2px solid white;
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
highlightMarker = new maplibregl.Marker({ element: el })
.setLngLat(coords[idx])
.addTo(map)
})
</script>
<div
bind:this={container}
style:height
class="w-full rounded-lg overflow-hidden border"
class:cursor-crosshair={placing}
></div>
{#if placing}
<p class="text-sm text-muted-foreground mt-2">
Klicke auf die Karte, um die Stelle zu markieren.
</p>
{/if}

View file

@ -1,191 +0,0 @@
import { describe, it, expect } from 'vitest'
import { parseGpx, haversine, simplify } from './gpx'
// Zwei Punkte in Hersbruck, rund 1,4 km auseinander.
const SAMPLE = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>Testtrail</name><trkseg>
<trkpt lat="49.5100" lon="11.4300"><ele>340</ele></trkpt>
<trkpt lat="49.5110" lon="11.4320"><ele>350</ele></trkpt>
<trkpt lat="49.5120" lon="11.4340"><ele>345</ele></trkpt>
<trkpt lat="49.5130" lon="11.4360"><ele>400</ele></trkpt>
</trkseg></trk>
</gpx>`
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 = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="49.510" lon="11.430"><ele>300</ele></trkpt>
<trkpt lat="49.511" lon="11.431"><ele>301</ele></trkpt>
<trkpt lat="49.512" lon="11.432"><ele>302</ele></trkpt>
<trkpt lat="49.513" lon="11.433"><ele>303</ele></trkpt>
</trkseg></trk></gpx>`
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 = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="49.510" lon="11.430"><ele>300</ele></trkpt>
<trkpt lat="49.511" lon="11.431"><ele>302</ele></trkpt>
<trkpt lat="49.512" lon="11.432"><ele>300</ele></trkpt>
<trkpt lat="49.513" lon="11.433"><ele>301</ele></trkpt>
<trkpt lat="49.514" lon="11.434"><ele>299</ele></trkpt>
<trkpt lat="49.515" lon="11.435"><ele>300</ele></trkpt>
</trkseg></trk></gpx>`
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 = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="49.510" lon="11.430"/>
<trkpt lat="49.511" lon="11.431"/>
</trkseg></trk></gpx>`
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 = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><rte>
<rtept lat="49.510" lon="11.430"><ele>300</ele></rtept>
<rtept lat="49.511" lon="11.431"><ele>310</ele></rtept>
</rte></gpx>`
expect(parseGpx(route).points).toHaveLength(2)
})
it('überspringt Punkte ohne lat/lon statt sie auf 0,0 zu setzen', () => {
const broken = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="49.510" lon="11.430"><ele>300</ele></trkpt>
<trkpt><ele>310</ele></trkpt>
<trkpt lat="49.512" lon="11.432"><ele>320</ele></trkpt>
</trkseg></trk></gpx>`
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 = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"></gpx>`
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 += `<trkpt lat="${49.5 + i * 0.00002}" lon="${11.4 + Math.sin(i / 300) * 0.01}"><ele>${300 + i * 0.01}</ele></trkpt>`
}
const r = parseGpx(`<?xml version="1.0"?><gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>${pts}</trkseg></trk></gpx>`)
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])
}
})
})

View file

@ -1,224 +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),
}
}

View file

@ -1,152 +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<TrailFlagsResponse[]>([])
loading = $state(false)
error = $state<string | null>(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<TrailFlagsResponse>) {
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(): Promise<number> {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
if (!this.canManage) {
throw new Error('Dafür brauchst du Administratorrechte im Team.')
}
const existing = new Set(this.scoped.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<TrailFlagStore>(KEY)
}

View file

@ -1,116 +0,0 @@
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import type { TrailMarkersResponse } from '$lib/types'
const KEY = Symbol('trailMarker')
export class TrailMarkerStore {
records = $state<TrailMarkersResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
/** Alle Marker eines Trails, jüngste zuerst */
byTrail(trailId: string): TrailMarkersResponse[] {
return this.records
.filter((r) => r.trail === trailId)
.sort((a, b) => (a.created < b.created ? 1 : -1))
}
/** Nur die offenen — was auf der Karte auffallen soll */
openByTrail(trailId: string): TrailMarkersResponse[] {
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: TrailMarkersResponse, 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)
)
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('trail_markers').getFullList({
sort: '-created',
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) => {
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_markers subscribe failed:', e))
}
async create(data: { trail: string; flag: 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<TrailMarkersResponse>) {
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<TrailMarkerStore>(KEY)
}

View file

@ -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<TrailVersionsResponse[]>([])
loading = $state(false)
error = $state<string | null>(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<TrailVersionStore>(KEY)
}

View file

@ -1,119 +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<TrailsResponse[]>([])
loading = $state(false)
error = $state<string | null>(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<TrailsResponse>) {
return await api.collection('trails').update(id, data)
}
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<TrailStore>(KEY)
}

View file

@ -11,11 +11,6 @@ export enum Collections {
Runs = "runs",
Teams = "teams",
Times = "times",
TrailComments = "trail_comments",
TrailFlags = "trail_flags",
TrailMarkers = "trail_markers",
TrailVersions = "trail_versions",
Trails = "trails",
Users = "users",
}
@ -118,81 +113,6 @@ export type TimesRecord = {
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
id: string
lat: number
lng: number
note?: string
resolved?: boolean
team: RecordIdString
trail: RecordIdString
updated?: IsoDateString
}
export type TrailVersionsRecord<Tbounds = unknown, Tcoord_distances = unknown, Televation = unknown, Tgeojson = unknown> = {
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
stewards?: RecordIdString[]
team: RecordIdString
updated?: IsoDateString
}
export type UsersRecord = {
avatar?: string
created?: IsoDateString
@ -213,11 +133,6 @@ export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSys
export type RunsResponse<Texpand = unknown> = Required<RunsRecord> & BaseSystemFields<Texpand>
export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand>
export type TrailCommentsResponse<Texpand = unknown> = Required<TrailCommentsRecord> & BaseSystemFields<Texpand>
export type TrailFlagsResponse<Texpand = unknown> = Required<TrailFlagsRecord> & BaseSystemFields<Texpand>
export type TrailMarkersResponse<Texpand = unknown> = Required<TrailMarkersRecord> & BaseSystemFields<Texpand>
export type TrailVersionsResponse<Tbounds = unknown, Tcoord_distances = unknown, Televation = unknown, Tgeojson = unknown, Texpand = unknown> = Required<TrailVersionsRecord<Tbounds, Tcoord_distances, Televation, Tgeojson>> & BaseSystemFields<Texpand>
export type TrailsResponse<Texpand = unknown> = Required<TrailsRecord> & BaseSystemFields<Texpand>
export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSystemFields<Texpand>
// Types containing all Records and Responses, useful for creating typing helper functions
@ -228,11 +143,6 @@ export type CollectionRecords = {
runs: RunsRecord
teams: TeamsRecord
times: TimesRecord
trail_comments: TrailCommentsRecord
trail_flags: TrailFlagsRecord
trail_markers: TrailMarkersRecord
trail_versions: TrailVersionsRecord
trails: TrailsRecord
users: UsersRecord
}
@ -242,11 +152,6 @@ export type CollectionResponses = {
runs: RunsResponse
teams: TeamsResponse
times: TimesResponse
trail_comments: TrailCommentsResponse
trail_flags: TrailFlagsResponse
trail_markers: TrailMarkersResponse
trail_versions: TrailVersionsResponse
trails: TrailsResponse
users: UsersResponse
}
@ -259,10 +164,5 @@ export type TypedPocketBase = PocketBase & {
collection(idOrName: 'runs'): RecordService<RunsResponse>
collection(idOrName: 'teams'): RecordService<TeamsResponse>
collection(idOrName: 'times'): RecordService<TimesResponse>
collection(idOrName: 'trail_comments'): RecordService<TrailCommentsResponse>
collection(idOrName: 'trail_flags'): RecordService<TrailFlagsResponse>
collection(idOrName: 'trail_markers'): RecordService<TrailMarkersResponse>
collection(idOrName: 'trail_versions'): RecordService<TrailVersionsResponse>
collection(idOrName: 'trails'): RecordService<TrailsResponse>
collection(idOrName: 'users'): RecordService<UsersResponse>
}

View file

@ -7,15 +7,11 @@
import { setRunContext } from '$lib/stores/runs.svelte'
import { setRiderContext } from '$lib/stores/riders.svelte'
import { setTimeContext } from '$lib/stores/times.svelte'
import { setTrailContext } from '$lib/stores/trails.svelte'
import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import Logo from '$lib/components/Logo.svelte'
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
import { Home, Calendar, Flag, Timer, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-svelte'
import { Home, Calendar, Flag, Timer, LogOut, Menu, X, Moon, Sun } from 'lucide-svelte'
import { toggleMode, mode } from 'mode-watcher'
import { onDestroy, onMount } from 'svelte'
@ -28,10 +24,6 @@
const runs = setRunContext()
const riders = setRiderContext()
const times = setTimeContext()
const trails = setTrailContext()
const trailVersions = setTrailVersionContext()
const trailFlags = setTrailFlagContext()
const trailMarkers = setTrailMarkerContext()
let isMobileMenuOpen = $state(false)
@ -43,18 +35,11 @@
// Teams zuerst laden, weil die anderen Stores nach team filtern
await teams.load()
teams.subscribe()
await Promise.all([
events.load(), runs.load(), riders.load(), times.load(),
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
])
await Promise.all([events.load(), runs.load(), riders.load(), times.load()])
events.subscribe()
runs.subscribe()
riders.subscribe()
times.subscribe()
trails.subscribe()
trailVersions.subscribe()
trailFlags.subscribe()
trailMarkers.subscribe()
})
onDestroy(() => {
@ -63,10 +48,6 @@
runs.destroy()
riders.destroy()
times.destroy()
trails.destroy()
trailVersions.destroy()
trailFlags.destroy()
trailMarkers.destroy()
})
function handleLogout() {
@ -85,10 +66,8 @@
const nav = [
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
{ href: '/dashboard/timing', icon: Timer, label: 'Zeitnahme' },
{ href: '/dashboard/results', icon: Flag, label: 'Ergebnisse' },
{ href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' },
]
</script>
@ -97,18 +76,9 @@
<header class="border-b bg-card">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-2">
<div class="flex items-center gap-3">
<Logo />
<div class="hidden md:block">
<TeamSwitcher />
</div>
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<span class="text-lg font-semibold tracking-tight">Zeitnahme</span>
</div>
<nav class="hidden md:flex items-center gap-1">
@ -125,7 +95,19 @@
</nav>
<div class="flex items-center gap-2">
<div class="hidden md:block">
<TeamSwitcher />
</div>
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<div class="hidden md:flex items-center gap-2">
<Separator orientation="vertical" class="h-6" />
<div class="text-sm text-right">
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
<p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>

View file

@ -1,221 +0,0 @@
<script lang="ts">
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { app } from '$lib/stores/app.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte'
import type { TrailFlagsResponse } from '$lib/types'
const flags = getTrailFlagContext()
let dialog = $state(false)
let editing = $state<TrailFlagsResponse | null>(null)
let form = $state<{ label: string; icon: string; color: string; severity: string }>({
label: '',
icon: 'Info',
color: '#dc2626',
severity: 'warnung',
})
let error = $state<string | null>(null)
let seeding = $state(false)
const SEVERITIES = [
{ value: 'info', label: 'Info' },
{ value: 'warnung', label: 'Warnung' },
{ value: 'kritisch', label: 'Kritisch' },
] as const
function openCreate() {
editing = null
form = { label: '', icon: 'Info', color: '#dc2626', severity: 'warnung' }
error = null
dialog = true
}
function openEdit(flag: TrailFlagsResponse) {
editing = flag
form = {
label: flag.label,
icon: flag.icon ?? 'Info',
color: flag.color ?? '#dc2626',
severity: flag.severity ?? 'warnung',
}
error = null
dialog = true
}
async function save() {
if (!form.label.trim()) {
error = 'Bitte eine Bezeichnung angeben.'
return
}
if (!/^#[0-9a-fA-F]{6}$/.test(form.color)) {
error = 'Bitte eine Farbe im Format #rrggbb angeben.'
return
}
try {
if (editing) await flags.edit(editing.id, { ...form } as Parameters<typeof flags.edit>[1])
else await flags.create({ ...form })
dialog = false
} catch (e: any) {
error = e.message ?? 'Der Flag-Typ konnte nicht gespeichert werden.'
}
}
function remove(flag: TrailFlagsResponse) {
app.confirm.request({
title: 'Flag-Typ löschen?',
text: `„${flag.label}" wird entfernt. Marker, die ihn verwenden, verhindern das Löschen.`,
yes: async () => {
try {
await flags.remove(flag.id)
app.confirm.close()
} catch (e: any) {
app.confirm.close()
// Die deleteRule verhindert das Löschen, solange ein Marker
// den Typ verwendet — PocketBase meldet das nur technisch.
alert(
`„${flag.label}" konnte nicht gelöscht werden. ` +
'Vermutlich verwendet ihn noch ein Marker auf einem Trail. ' +
'Entferne zuerst die betroffenen Marker oder markiere sie als erledigt.',
)
}
},
no: () => app.confirm.close(),
})
}
async function seed() {
seeding = true
try {
const n = await flags.seedDefaults()
if (n === 0) alert('Alle Standard-Typen sind bereits vorhanden.')
} catch (e: any) {
alert(e.message ?? 'Die Standard-Typen konnten nicht angelegt werden.')
} finally {
seeding = false
}
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Flag-Typen</h1>
<p class="text-muted-foreground text-sm">
Wofür sich Marker auf einem Trail setzen lassen.
</p>
{#if !flags.canManage}
<p class="text-sm text-muted-foreground mt-3">
Flag-Typen verwaltet, wer das Team führt. Du kannst sie beim Setzen
von Markern verwenden.
</p>
{/if}
</div>
{#if flags.canManage}
<div class="flex gap-2">
{#if flags.scoped.length === 0}
<Button variant="outline" onclick={seed} disabled={seeding}>
<Sparkles class="size-4 mr-2" />
{seeding ? 'Wird angelegt …' : 'Standardsatz anlegen'}
</Button>
{/if}
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
Neuer Typ
</Button>
</div>
{/if}
</div>
{#if flags.scoped.length === 0}
<Card>
<CardContent class="py-10 text-center text-muted-foreground">
{#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}
</CardContent>
</Card>
{:else}
<div class="space-y-2">
{#each flags.scoped as flag (flag.id)}
<Card>
<CardContent class="py-3 flex items-center gap-3">
<span class="size-4 rounded-full shrink-0" style:background={flag.color}></span>
<span class="font-medium flex-1">{flag.label}</span>
<Badge variant={flag.severity === 'kritisch' ? 'destructive' : 'secondary'}>
{flag.severity}
</Badge>
{#if flags.canManage}
<Button variant="ghost" size="sm" onclick={() => openEdit(flag)}>
<Pencil class="size-4" />
</Button>
<Button variant="ghost" size="sm" onclick={() => remove(flag)}>
<Trash2 class="size-4" />
</Button>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
</div>
<Dialog.Root bind:open={dialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{editing ? 'Flag-Typ bearbeiten' : 'Neuer Flag-Typ'}</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="flag-label">Bezeichnung</Label>
<Input id="flag-label" bind:value={form.label} placeholder="z. B. Baum quer" />
</div>
<div class="space-y-2">
<Label for="flag-color">Farbe</Label>
<div class="flex gap-2 items-center">
<input id="flag-color" type="color" bind:value={form.color} class="h-9 w-16 rounded border" />
<Input bind:value={form.color} class="flex-1" />
</div>
</div>
<div class="space-y-2">
<Label>Schweregrad</Label>
<div class="flex gap-2">
{#each SEVERITIES as s (s.value)}
<Button
variant={form.severity === s.value ? 'default' : 'outline'}
size="sm"
onclick={() => (form.severity = s.value)}
>
{s.label}
</Button>
{/each}
</div>
</div>
<div class="space-y-2">
<Label for="flag-icon">Icon</Label>
<Input id="flag-icon" bind:value={form.icon} placeholder="Lucide-Name, z. B. TreePine" />
<p class="text-xs text-muted-foreground">
Name eines Icons von lucide.dev, in Schreibweise wie „TreePine".
</p>
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (dialog = false)}>Abbrechen</Button>
<Button onclick={save}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,199 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import { Plus, Route, AlertTriangle } from 'lucide-svelte'
import type { TrailVersionsResponse } from '$lib/types'
const trails = getTrailContext()
const versions = getTrailVersionContext()
const markers = getTrailMarkerContext()
let dialog = $state(false)
let form = $state({ name: '', description: '' })
let saving = $state(false)
let error = $state<string | null>(null)
const STATUS_LABEL = {
offen: 'Offen',
eingeschraenkt: 'Eingeschränkt',
gesperrt: 'Gesperrt',
} as const
const STATUS_VARIANT = {
offen: 'default',
eingeschraenkt: 'secondary',
gesperrt: 'destructive',
} as const
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
/** Aktive Version eines Trails, für Kennzahlen und Kartenlinie */
function currentVersion(trailId: string): TrailVersionsResponse<
[[number, number], [number, number]] | null,
unknown,
unknown,
LineGeoJSON | null
> | null {
const trail = trails.getById(trailId)
if (!trail?.current) return null
return (versions.records.find((v) => v.id === trail.current) ?? null) as TrailVersionsResponse<
[[number, number], [number, number]] | null,
unknown,
unknown,
LineGeoJSON | null
> | null
}
function formatKm(m: number | undefined) {
// Nur fehlende Werte zeigen ""; 0 ist eine gültige Länge.
if (m == null) return ''
return `${(m / 1000).toFixed(1)} km`
}
async function save() {
if (saving) return
if (!form.name.trim()) {
error = 'Bitte einen Namen angeben.'
return
}
saving = true
error = null
try {
const rec = await trails.create({
name: form.name.trim(),
description: form.description,
})
dialog = false
form = { name: '', description: '' }
await goto(`/dashboard/trails/${rec.id}`)
} catch (e: any) {
error = e.message ?? 'Der Trail konnte nicht angelegt werden.'
} finally {
saving = false
}
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Trails</h1>
<p class="text-muted-foreground text-sm">
{trails.scoped.length}
{trails.scoped.length === 1 ? 'Trail' : 'Trails'} im Team
</p>
</div>
<Button onclick={() => (dialog = true)}>
<Plus class="size-4 mr-2" />
Neuer Trail
</Button>
</div>
{#if trails.loading}
<p class="text-muted-foreground">Trails werden geladen …</p>
{:else if trails.scoped.length === 0}
<Card>
<CardContent class="py-10 text-center space-y-3">
<Route class="size-10 mx-auto text-muted-foreground" />
<p class="text-muted-foreground">
Noch kein Trail angelegt. Leg einen an und lade eine GPX-Datei hoch.
</p>
<Button onclick={() => (dialog = true)}>
<Plus class="size-4 mr-2" />
Neuer Trail
</Button>
</CardContent>
</Card>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each trails.scoped as trail (trail.id)}
{@const version = currentVersion(trail.id)}
{@const open = markers.openByTrail(trail.id).length}
<a href="/dashboard/trails/{trail.id}" class="block">
<Card class="h-full hover:border-primary transition-colors">
<CardHeader class="pb-3">
<div class="flex items-start justify-between gap-2">
<CardTitle class="text-base">{trail.name}</CardTitle>
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
{STATUS_LABEL[trail.status] ?? trail.status}
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-3">
{#if version?.geojson}
<TrailMap
geojson={version.geojson}
bounds={version.bounds}
status={trail.status}
height="140px"
/>
{:else}
<div class="h-[140px] rounded-lg border border-dashed grid place-items-center text-sm text-muted-foreground">
Noch keine GPX-Datei
</div>
{/if}
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatKm(version?.distance_m)}</span>
<span>{version?.ascent_m ? `${version.ascent_m} hm` : ''}</span>
{#if open > 0}
<span class="flex items-center gap-1 text-amber-600 ml-auto">
<AlertTriangle class="size-4" />
{open}
</span>
{/if}
</div>
</CardContent>
</Card>
</a>
{/each}
</div>
{/if}
</div>
<Dialog.Root bind:open={dialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neuer Trail</Dialog.Title>
<Dialog.Description>
Die GPX-Datei lädst du im nächsten Schritt hoch.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="trail-name">Name</Label>
<Input
id="trail-name"
bind:value={form.name}
placeholder="z. B. Steinberg-Abfahrt"
onkeydown={(e) => e.key === 'Enter' && save()}
/>
</div>
<div class="space-y-2">
<Label for="trail-desc">Beschreibung</Label>
<Input id="trail-desc" bind:value={form.description} placeholder="optional" />
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (dialog = false)}>Abbrechen</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Wird angelegt …' : 'Anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,569 +0,0 @@
<script lang="ts">
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { api, auth, getFileURL } from '$lib/stores/pocketbase.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import * as Select from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
ArrowLeft, Upload, MapPin, MessageSquare, History,
Check, Trash2, Download, Plus, AlertTriangle,
} from 'lucide-svelte'
import DOMPurify from 'dompurify'
import { browser } from '$app/environment'
import type { TrailCommentsResponse, TrailVersionsResponse } from '$lib/types'
// Generics der JSON-Felder, wie in trails/+page.svelte — die
// Rohdaten kommen ungetypt aus PocketBase zurück.
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
type Elevation = { d: number; ele: number }[]
type Version = TrailVersionsResponse<
[[number, number], [number, number]] | null,
number[] | null,
Elevation | null,
LineGeoJSON | null
>
const trails = getTrailContext()
const versions = getTrailVersionContext()
const flags = getTrailFlagContext()
const markers = getTrailMarkerContext()
const teams = getTeamContext()
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
const canEdit = $derived(trail ? trails.canEdit(trail) : false)
const team = $derived(trail ? teams.records.find((t) => t.id === trail.team) : undefined)
/**
* description ist ein editor-Feld und enthält HTML. Gesetzt wird es zwar
* nur von Paten und Admins, aber ungefiltert ausgegeben wäre es ein Weg,
* Skriptcode bei allen Team-Mitgliedern auszuführen. DOMPurify braucht ein
* DOM — beim serverseitigen Rendern (kein `window`/`document`) liefern
* wir stattdessen leer; der Browser holt das beim Hydrieren nach.
*/
const safeDescription = $derived(
browser && trail?.description ? DOMPurify.sanitize(trail.description) : '',
)
const version = $derived.by((): Version | null => {
if (!trail?.current) return null
return (versions.records.find((v) => v.id === trail.current) ?? null) as Version | null
})
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
const trailMarkers = $derived(trail ? markers.byTrail(trail.id) : [])
// Für die Karte aufbereitete Marker
const mapMarkers = $derived(
trailMarkers.map((m) => {
const flag = flags.getById(m.flag)
return {
id: m.id,
lat: m.lat,
lng: m.lng,
color: flag?.color ?? '#64748b',
resolved: !!m.resolved,
label: flag?.label ?? 'Marker',
}
}),
)
let tab = $state<'marker' | 'kommentare' | 'versionen'>('marker')
let highlight = $state<number | null>(null)
// --- GPX-Upload ------------------------------------------------------
let uploadDialog = $state(false)
let uploadFile = $state<File | null>(null)
let uploadNote = $state('')
let uploading = $state(false)
let uploadError = $state<string | null>(null)
async function doUpload() {
if (!trail || !uploadFile) return
uploading = true
uploadError = null
try {
await versions.upload(trail.id, uploadFile, uploadNote)
uploadDialog = false
uploadFile = null
uploadNote = ''
} catch (e: any) {
// parseGpx wirft mit verständlichem Text — direkt zeigen. Der
// Upload läuft in zwei Schritten (Version anlegen, dann aktivieren);
// scheitert der zweite, existiert die Version schon.
uploadError = (e.message ?? 'Die Datei konnte nicht verarbeitet werden.')
+ ' Prüfe unter „Versionen“, ob sie trotzdem angelegt wurde.'
} finally {
uploading = false
}
}
// --- Marker setzen ---------------------------------------------------
let placing = $state(false)
let markerDialog = $state(false)
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
let markerFlag = $state('')
let markerNote = $state('')
let markerError = $state<string | null>(null)
function onPlace(coords: { lat: number; lng: number }) {
markerCoords = coords
markerFlag = flags.scoped[0]?.id ?? ''
markerNote = ''
markerError = null
placing = false
markerDialog = true
}
async function saveMarker() {
if (!trail || !markerCoords) return
if (!markerFlag) {
markerError = 'Bitte einen Flag-Typ wählen.'
return
}
try {
await markers.create({
trail: trail.id,
flag: markerFlag,
lat: markerCoords.lat,
lng: markerCoords.lng,
note: markerNote,
})
markerDialog = false
markerCoords = null
} catch (e: any) {
markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.'
}
}
// --- Kommentare ------------------------------------------------------
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
let comments = $state<TrailCommentsResponse[]>([])
let commentText = $state('')
let commentError = $state<string | null>(null)
let unsubComments: (() => void) | null = null
$effect(() => {
const id = trail?.id
if (!id) return
let cancelled = false
api.collection('trail_comments')
.getFullList({ filter: `trail="${id}"`, sort: '-created', requestKey: null })
.then((r) => {
if (!cancelled) comments = r
})
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
api.collection('trail_comments')
.subscribe('*', (e) => {
if (e.record.trail !== id) return
const idx = comments.findIndex((c) => c.id === e.record.id)
if (e.action === 'create' && idx === -1) comments = [e.record, ...comments]
else if (e.action === 'update' && idx !== -1) comments[idx] = e.record
else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== e.record.id)
})
.then((u) => {
if (cancelled) u()
else unsubComments = u
})
.catch((e) => console.warn('trail_comments subscribe failed:', e))
return () => {
cancelled = true
unsubComments?.()
unsubComments = null
}
})
async function addComment() {
if (!trail || !commentText.trim()) return
commentError = null
try {
await api.collection('trail_comments').create({
trail: trail.id,
team: trail.team,
text: commentText.trim(),
created_by: auth.user?.id,
})
commentText = ''
} catch (e: any) {
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'
}
}
// --- Hilfsfunktionen -------------------------------------------------
const STATUS_LABEL = {
offen: 'Offen',
eingeschraenkt: 'Eingeschränkt',
gesperrt: 'Gesperrt',
} as const
const STATUS_VARIANT = {
offen: 'default',
eingeschraenkt: 'secondary',
gesperrt: 'destructive',
} as const
function formatDate(s: string) {
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
}
async function setStatus(status: string) {
if (!trail) return
await trails.edit(trail.id, { status } as any)
}
</script>
{#if !trail}
<div class="space-y-4">
<Button variant="ghost" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" />
Zurück
</Button>
<p class="text-muted-foreground">Trail nicht gefunden.</p>
</div>
{:else}
<div class="space-y-6">
<!-- Kopf -->
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" />
Trails
</Button>
<h1 class="text-2xl font-semibold">{trail.name}</h1>
{#if safeDescription}
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
{/if}
</div>
<div class="flex items-center gap-2">
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
{STATUS_LABEL[trail.status] ?? trail.status}
</Badge>
{#if canEdit}
<Button variant="outline" size="sm" onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
</div>
</div>
{#if canEdit}
<div class="flex gap-2">
{#each ['offen', 'eingeschraenkt', 'gesperrt'] as s (s)}
<Button
variant={trail.status === s ? 'default' : 'outline'}
size="sm"
onclick={() => setStatus(s)}
>
{STATUS_LABEL[s as keyof typeof STATUS_LABEL]}
</Button>
{/each}
</div>
{/if}
<!-- Karte -->
<Card>
<CardContent class="pt-6 space-y-4">
{#if version?.geojson}
<TrailMap
geojson={version.geojson}
bounds={version.bounds}
markers={mapMarkers}
status={trail.status}
{placing}
coordDistances={version.coord_distances ?? []}
highlightDistance={highlight}
onplace={onPlace}
/>
<div class="flex flex-wrap items-center gap-6 text-sm">
<span><strong>{(version.distance_m / 1000).toFixed(1)}</strong> km</span>
<span><strong>{version.ascent_m}</strong> hm</span>
<Button
variant={placing ? 'default' : 'outline'}
size="sm"
class="ml-auto"
onclick={() => (placing = !placing)}
disabled={flags.scoped.length === 0}
>
<MapPin class="size-4 mr-2" />
{placing ? 'Abbrechen' : 'Marker setzen'}
</Button>
</div>
{#if flags.scoped.length === 0}
<p class="text-sm text-muted-foreground">
Es gibt noch keine Flag-Typen.
<a href="/dashboard/settings/flags" class="underline">Jetzt anlegen</a>
</p>
{/if}
{#if version.elevation?.length}
<Separator />
<ElevationProfile
elevation={version.elevation}
onhover={(i) => (highlight = i)}
/>
{/if}
{:else}
<div class="py-12 text-center space-y-3">
<Upload class="size-10 mx-auto text-muted-foreground" />
<p class="text-muted-foreground">Für diesen Trail gibt es noch keine GPX-Datei.</p>
{#if canEdit}
<Button onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
<!-- Reiter -->
<div class="flex gap-2 border-b">
<button
class="px-4 py-2 text-sm border-b-2 -mb-px"
class:border-primary={tab === 'marker'}
class:border-transparent={tab !== 'marker'}
onclick={() => (tab = 'marker')}
>
<MapPin class="size-4 inline mr-1" />
Marker ({trailMarkers.length})
</button>
<button
class="px-4 py-2 text-sm border-b-2 -mb-px"
class:border-primary={tab === 'kommentare'}
class:border-transparent={tab !== 'kommentare'}
onclick={() => (tab = 'kommentare')}
>
<MessageSquare class="size-4 inline mr-1" />
Kommentare ({comments.length})
</button>
<button
class="px-4 py-2 text-sm border-b-2 -mb-px"
class:border-primary={tab === 'versionen'}
class:border-transparent={tab !== 'versionen'}
onclick={() => (tab = 'versionen')}
>
<History class="size-4 inline mr-1" />
Versionen ({trailVersions.length})
</button>
</div>
{#if tab === 'marker'}
{#if trailMarkers.length === 0}
<p class="text-muted-foreground text-sm">
Noch keine Marker. Setze einen über „Marker setzen" auf der Karte.
</p>
{:else}
<div class="space-y-2">
{#each trailMarkers as m (m.id)}
{@const flag = flags.getById(m.flag)}
<Card class={m.resolved ? 'opacity-60' : ''}>
<CardContent class="py-3 flex items-center gap-3">
<span
class="size-3 rounded-full shrink-0"
style:background={flag?.color ?? '#64748b'}
></span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
{#if m.note}
<p class="text-sm text-muted-foreground">{m.note}</p>
{/if}
<p class="text-xs text-muted-foreground">{formatDate(m.created)}</p>
</div>
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
<Button
variant="ghost"
size="sm"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => {
try {
await markers.toggleResolved(m.id)
} catch (e: any) {
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
}
}}
>
<Check class="size-4" />
</Button>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
{:else if tab === 'kommentare'}
<div class="space-y-4">
<div class="flex gap-2">
<Input
bind:value={commentText}
placeholder="Kommentar schreiben …"
onkeydown={(e) => e.key === 'Enter' && addComment()}
/>
<Button onclick={addComment} disabled={!commentText.trim()}>
<Plus class="size-4" />
</Button>
</div>
{#if commentError}
<p class="text-sm text-destructive">{commentError}</p>
{/if}
{#if comments.length === 0}
<p class="text-muted-foreground text-sm">Noch keine Kommentare.</p>
{:else}
<div class="space-y-2">
{#each comments as c (c.id)}
<Card>
<CardContent class="py-3">
<p class="text-sm">{c.text}</p>
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
</CardContent>
</Card>
{/each}
</div>
{/if}
</div>
{:else}
{#if trailVersions.length === 0}
<p class="text-muted-foreground text-sm">Noch keine Version hochgeladen.</p>
{:else}
<div class="space-y-2">
{#each trailVersions as v (v.id)}
<Card class={v.id === trail.current ? 'border-primary' : ''}>
<CardContent class="py-3 flex items-center gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{formatDate(v.created)}</p>
{#if v.id === trail.current}
<Badge>Aktiv</Badge>
{/if}
</div>
<p class="text-sm text-muted-foreground">
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
{#if v.note}· {v.note}{/if}
</p>
</div>
{#if v.gpx}
<Button variant="ghost" size="sm" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
<Download class="size-4" />
</Button>
{/if}
{#if canEdit && v.id !== trail.current}
<Button
variant="outline"
size="sm"
onclick={() => versions.activate(trail.id, v.id)}
>
Aktivieren
</Button>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
{/if}
</div>
{/if}
<!-- GPX-Upload -->
<Dialog.Root bind:open={uploadDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>GPX-Datei hochladen</Dialog.Title>
<Dialog.Description>
Die bisherige Version bleibt erhalten; die neue wird zur aktiven.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="gpx-file">Datei</Label>
<input
id="gpx-file"
type="file"
accept=".gpx,application/gpx+xml,application/xml,text/xml"
class="block w-full text-sm"
onchange={(e) => (uploadFile = e.currentTarget.files?.[0] ?? null)}
/>
</div>
<div class="space-y-2">
<Label for="gpx-note">Notiz</Label>
<Input id="gpx-note" bind:value={uploadNote} placeholder="z. B. Umleitung am Steinbruch" />
</div>
{#if uploadError}
<p class="text-sm text-destructive">{uploadError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (uploadDialog = false)}>Abbrechen</Button>
<Button onclick={doUpload} disabled={!uploadFile || uploading}>
{uploading ? 'Wird verarbeitet …' : 'Hochladen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Marker anlegen -->
<Dialog.Root bind:open={markerDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Marker setzen</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label>Typ</Label>
<div class="flex flex-wrap gap-2">
{#each flags.scoped as f (f.id)}
<Button
variant={markerFlag === f.id ? 'default' : 'outline'}
size="sm"
onclick={() => (markerFlag = f.id)}
>
<span class="size-2 rounded-full mr-2" style:background={f.color}></span>
{f.label}
</Button>
{/each}
</div>
</div>
<div class="space-y-2">
<Label for="marker-note">Notiz</Label>
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />
</div>
{#if markerError}
<p class="text-sm text-destructive">{markerError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (markerDialog = false)}>Abbrechen</Button>
<Button onclick={saveMarker}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -11,7 +11,4 @@ export default defineConfig({
strictPort: true,
open: `http://${pkg.name}.localhost:31337`,
},
test: {
environment: 'jsdom',
},
} as any)
})