Compare commits

..

30 commits

Author SHA1 Message Date
Daniel Michelberger
1257456308 docs: Plan-Korrekturen aus der Umsetzung nachziehen
- {@html} der Trail-Beschreibung mit DOMPurify absichern
- fehlender Key in der Status-Schleife

Beides fiel beim Autofixer-Lauf in Task 8 auf und stand so im Plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 19:30:44 +02:00
Daniel Michelberger
ccc1d931ab fix: Flag-Verwaltung dauerhaft über die Navigation erreichbar machen
/dashboard/settings/flags war nur über einen Link im Leerzustand
verlinkt — sobald ein Team einen Flag-Typ hat, verschwand der einzige
Weg dorthin. Neuer Navigationspunkt "Einstellungen" im Dashboard-Menü.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBMBfip6BAG8VV9raSf6vu
2026-08-06 19:28:54 +02:00
Daniel Michelberger
331c4cfed1 fix: Marker-Aktionen nur für Berechtigte anbieten
Der Button "Als erledigt markieren" stand bisher an jedem Marker,
obwohl die updateRule der Collection Änderungen nur dem Ersteller,
Trail-Paten oder Team-Admins erlaubt — für alle anderen scheiterte
toggleResolved mit einer stillen unhandled Rejection. Neuer Getter
TrailMarkerStore.canEdit() spiegelt die Regel im Frontend; der Button
ist jetzt entsprechend gegated und Fehler landen als sichtbarer Alert
statt zu verschwinden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBMBfip6BAG8VV9raSf6vu
2026-08-06 19:28:46 +02:00
Daniel Michelberger
7b1df79a37 fix: Höhenprofil-Hervorhebung über Distanz statt Index koppeln
elevation enthält einen Eintrag pro Originalpunkt, geojson.coordinates
nur die per Douglas-Peucker vereinfachten Punkte. Ein gemeinsamer Index
zeigte deshalb ab rund 3 % des Profils auf das Streckenende. Die neue
Distanz-Kopplung nutzt coord_distances (kumulative Distanz je
vereinfachter Koordinate), um beim Überfahren des Höhenprofils die
nächstgelegene Stelle auf der Karte zu finden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBMBfip6BAG8VV9raSf6vu
2026-08-06 19:28:28 +02:00
Daniel Michelberger
08af521d19 fix: Bestätigungsdialog und Farbvalidierung bei Flag-Verwaltung
- Ersetze native confirm() durch app.confirm.request() mit themingfähigem Dialog
- Erweitere Fehlermeldung beim Löschen mit Hinweis auf Marker-Abhängigkeiten
- Validiere Hex-Farben (#rrggbb) vor dem Speichern
- Konsistenz mit Projektmuster (events, runs, riders, times)
2026-08-06 19:11:38 +02:00
Daniel Michelberger
c44ec4377f feat: Verwaltung der Flag-Typen
Die Flag-Verwaltungsseite ermöglicht Team-Admins und -Besitzern, die Marker-Typen
für Trails zu erstellen, bearbeiten und zu löschen. Einfache Mitglieder sehen die
Flags nur. Hinweistexte informieren über Berechtigungen und ermöglichen Nicht-Admins,
die Benutzeroberfläche zu verstehen.
2026-08-06 19:06:58 +02:00
Daniel Michelberger
8e256d0184 fix: HTML-Beschreibung sanitizen und Key in Status-Schleife ergänzen
DOMPurify filtert trail.description vor {@html}, da nur Paten/Admins das
editor-Feld setzen dürfen, ungefiltert aber Skriptcode bei allen
Team-Mitgliedern ausführen könnte. Der Sanitizer läuft browser-gated, da
DOMPurify serverseitig kein DOM hat. Zusätzlich Key in der
Status-Buttons-Schleife ergänzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:56:03 +02:00
Daniel Michelberger
a45c5d2908 feat: Trail-Detailseite mit Karte, Höhenprofil, Markern und Kommentaren
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:41:53 +02:00
Daniel Michelberger
7bc128601a fix: Karte lazy-loaded, formatKm bei 0, Enter im Dialog
- TrailMap: IntersectionObserver für WebGL-Kontexte (max 8-16 gleichzeitig)
  → Karte erst laden, wenn sichtbar, rootMargin 200px für frühes Laden
- Trail-Liste: formatKm zeigt 0 m als "0,0 km" statt "–"
- Trail-Anlegen: Enter im Namensfeld triggert save(), saving-Guard hinzugefügt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:34:58 +02:00
Daniel Michelberger
60d5891bc9 feat: Trail-Liste mit Vorschaukarten
Übersichtsseite zeigt alle Trails des Teams als Grid mit Kartvorschau,
Status-Badge und Kennzahlen. Dialog für Trail-Erstellung mit GPX-Upload
im nächsten Schritt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:27:33 +02:00
Daniel Michelberger
a0ccafaa91 fix: Marker beim Aufräumen entfernen, Fokusstil für Marker-Knöpfe 2026-08-06 18:23:53 +02:00
Daniel Michelberger
b1f6f8d1e5 docs: maplibre-gl hat keinen Default-Export — Importzeile im Plan korrigieren 2026-08-06 18:19:32 +02:00
Daniel Michelberger
060c4434b9 feat: Kartenkomponente mit MapLibre und Höhenprofil 2026-08-06 18:18:02 +02:00
Daniel Michelberger
616694a7f5 feat: Trail-Stores registrieren und Navigationseintrag ergänzen
Die vier Trail-Stores (trails, trailVersions, trailFlags, trailMarkers)
werden im Dashboard-Layout per Context bereitgestellt, laden ihre Daten
beim Mount, abonnieren Realtime-Updates und werden beim Unmount wieder
aufgeräumt. Zusätzlich ergänzt ein Navigationseintrag „Trails" mit dem
Route-Icon aus lucide-svelte.
2026-08-06 18:11:14 +02:00
Daniel Michelberger
f432b47df1 fix: canManage-Getter und Fehlerbehandlung in seedDefaults
- Neuer Getter canManage prüft, ob der aktive User Admin im Team ist
  (analog zu canEdit in trails.svelte.ts)
- seedDefaults() wirft jetzt gleich wenn keine Admin-Rechte vorhanden
  sind statt später 403 zu bekommen
- Bessere Fehlerbehandlung in der Schleife: bricht ab und zeigt,
  wie viele Flags bereits angelegt wurden
- Kommentar zu DEFAULT_FLAGS erweitert um das Label-Matching-Verhalten

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:07:40 +02:00
Daniel Michelberger
6fd459e938 feat: Stores für Flag-Typen und Trail-Marker
Zwei neue Store-Klassen für das Trail-Feature:
- TrailFlagStore: Verwaltet die Meldungsarten (wie "Baum quer")
  mit seedDefaults() für das Anlegen der Standardmenge
- TrailMarkerStore: Verwaltet verortete Meldungen auf Trails
  mit toggleResolved() zum Abhaken (nicht Löschen)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:43:54 +02:00
Daniel Michelberger
247ac8ec6b feat: Stores für Trails und GPX-Versionen
TrailStore verwaltet Trail-Datensätze mit Team-Filterung und Edit-Berechtigungen
für Trail-Paten, Team-Owner und Admins. TrailVersionStore verwaltet GPX-Versionen
mit Upload und Aktivierung (mit Prüfung auf Trail-Zugehörigkeit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:37:50 +02:00
Daniel Michelberger
efadbab48a docs: fehlerhaften Höhenmeter-Test im Plan korrigieren
Der Test "ignoriert Höhenschwankungen unter 3 m" nutzte 300->301->302->303
und erwartete 0 Höhenmeter. Das ist aber ein echter Anstieg von 3 m, nur fein
aufgezeichnet — bei einem langen gleichmäßigen Berganstieg hätte der Parser
die Höhenmeter systematisch verschluckt.

Jetzt zwei Tests, die die Fälle trennen: gleichmäßiger Anstieg in 1-m-Schritten
ergibt 3 hm, Schwanken um denselben Wert ergibt 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:35:54 +02:00
Daniel Michelberger
1eb25cd7e4 fix: Rausch-Schwelle auf >= und <=, Tests korrigiert, Fehlertext angepasst
- Operator auf >= und <= geändert (kumulativ zählen)
- Test "ignoriert Höhenschwankungen" ersetzt durch zwei separate Tests:
  * "erfasst einen gleichmäßigen Anstieg auch in feinen Schritten" (300→303 in 1m Schritten = 3m)
  * "ignoriert Rauschen, das um denselben Wert schwankt" (auf/ab ohne Gewinn = 0m)
- Fehlertext auf Brief-Fassung: "Die Datei ist kein gültiges XML."
- Test-Regex angepasst zum neuen Fehlertext

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:34:16 +02:00
Daniel Michelberger
f89f163e22 fix: Punkte ohne Koordinaten, Fehlertext, neuer Test für Null-Island
- Prüfe lat/lon auf null vor Number-Konvertierung (Number(null) ist 0, nicht NaN)
- Ergänze Test für übersprungene Punkte ohne Koordinaten
- Bessere Fehlermeldung für ungültiges XML

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:30:20 +02:00
Daniel Michelberger
ef8c05bd7c style: TeamSwitcher und Design-Umschalter nach links vor die Navigation 2026-08-06 17:21:03 +02:00
Daniel Michelberger
64c5139d71 feat: GPX-Parser mit Länge, Höhenmetern und Streckenvereinfachung
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:19:02 +02:00
Daniel Michelberger
f576550429 fix: Präzedenzfehler in trail_flags.deleteRule beheben
&& bindet stärker als || — ohne Klammern galt die
Marker-Referenzprüfung nur für den admins-Zweig. Ein Team-Owner konnte
dadurch einen noch von Markern referenzierten Flag-Typ löschen und
verwaiste Marker hinterlassen.

Klammerung in 1754500000_created_trails.js korrigiert, damit ein
Neuaufbau der Datenbank die richtige Regel von Anfang an bekommt.
1754500200_trail_flags_delete_rule.js (bereits mit der fehlerhaften
Regel angewendet) inhaltlich ebenfalls korrigiert, und eine neue
Migration 1754500300_trail_flags_delete_rule_precedence.js trägt die
Korrektur für bestehende Instanzen nach. Snapshot aktualisiert.
2026-08-06 17:13:35 +02:00
Daniel Michelberger
55f25fae0c fix: verwaiste trail_markers beim Löschen von trail_flags verhindern
trail_markers.flag ist required, hatte aber cascadeDelete: false — beim
Löschen eines Flag-Typs blieben Marker mit toter Referenz zurück. Die
deleteRule von trail_flags verbietet das Löschen jetzt, solange noch
Marker den Typ referenzieren.

1754500000_created_trails.js wurde direkt angepasst, damit ein Neuaufbau
der DB die Regel von Anfang an enthält. Da die Migration lokal bereits
angewendet war, trägt 1754500200_trail_flags_delete_rule.js dieselbe
Änderung für bestehende Instanzen nach. Snapshot in
1754400000_init_schema.js entsprechend aktualisiert.
2026-08-06 17:08:40 +02:00
Daniel Michelberger
9948073b25 fix: Avatar-Upload auf 5 MB begrenzen
users.avatar.maxSize stand auf 0 (unbegrenzt) — Ressourcenrisiko durch
beliebig große Uploads. Migration setzt das ursprüngliche Limit zurück.
2026-08-06 17:08:21 +02:00
Daniel Michelberger
c41a6cb056 feat: Collections für Trails, Versionen, Flags, Marker und Kommentare 2026-08-06 16:57:23 +02:00
Daniel Michelberger
ced89ed9d2 style: Titel "Zeitnahme" aus dem App-Header entfernen 2026-08-06 16:53:52 +02:00
Daniel Michelberger
2e8f92b6f6 docs: Umsetzungsplan für Trails 2026-08-06 16:42:18 +02:00
Daniel Michelberger
564632522e docs: Begründung für trails.current statt active-Flag ergänzen
Die Alternative (bool active auf trail_versions) käme ohne Zirkelbezug aus,
verlagert die Wahrheit aber in N Datensätze: zwei Schreibvorgänge beim
Umschalten, ohne Transaktion kurzzeitig zwei aktive Versionen möglich.
trails.current ändert genau ein Feld und kennt keinen ungültigen Zustand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:33:58 +02:00
Daniel Michelberger
f11f7b4dff docs: Spec für Trails mit GPX, Markern und Kommentaren
Trail-Katalog pro Team: GPX hochladen, auf Karte anzeigen, verortete
Meldungen setzen, kommentieren. Neue Uploads legen Versionen an statt zu
überschreiben; Marker und Kommentare hängen am Trail und überleben damit
einen Track-Austausch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:31:08 +02:00
25 changed files with 8231 additions and 40 deletions

1
.gitignore vendored
View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,271 @@
/// <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

@ -0,0 +1,17 @@
/// <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

@ -0,0 +1,26 @@
/// <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

@ -0,0 +1,24 @@
/// <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

@ -0,0 +1,20 @@
/// <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

@ -0,0 +1,318 @@
# 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,6 +13,8 @@
"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": {
@ -26,6 +28,7 @@
"@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",
@ -34,11 +37,14 @@
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.2",
"vite": "^7.1.7"
"vite": "^7.1.7",
"vitest": "^4.1.10"
},
"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

@ -0,0 +1,93 @@
<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

@ -0,0 +1,259 @@
<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

@ -0,0 +1,191 @@
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])
}
})
})

224
frontend/src/lib/gpx.ts Normal file
View file

@ -0,0 +1,224 @@
/**
* 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

@ -0,0 +1,152 @@
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

@ -0,0 +1,116 @@
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

@ -0,0 +1,121 @@
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

@ -0,0 +1,119 @@
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,6 +11,11 @@ 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",
}
@ -113,6 +118,81 @@ 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
@ -133,6 +213,11 @@ 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
@ -143,6 +228,11 @@ 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
}
@ -152,6 +242,11 @@ 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
}
@ -164,5 +259,10 @@ 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,11 +7,15 @@
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 } from 'lucide-svelte'
import { Home, Calendar, Flag, Timer, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-svelte'
import { toggleMode, mode } from 'mode-watcher'
import { onDestroy, onMount } from 'svelte'
@ -24,6 +28,10 @@
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)
@ -35,11 +43,18 @@
// 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()])
await Promise.all([
events.load(), runs.load(), riders.load(), times.load(),
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
])
events.subscribe()
runs.subscribe()
riders.subscribe()
times.subscribe()
trails.subscribe()
trailVersions.subscribe()
trailFlags.subscribe()
trailMarkers.subscribe()
})
onDestroy(() => {
@ -48,6 +63,10 @@
runs.destroy()
riders.destroy()
times.destroy()
trails.destroy()
trailVersions.destroy()
trailFlags.destroy()
trailMarkers.destroy()
})
function handleLogout() {
@ -66,8 +85,10 @@
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>
@ -76,9 +97,18 @@
<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-3">
<div class="flex items-center gap-2">
<Logo />
<span class="text-lg font-semibold tracking-tight">Zeitnahme</span>
<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>
<nav class="hidden md:flex items-center gap-1">
@ -95,19 +125,7 @@
</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

@ -0,0 +1,221 @@
<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

@ -0,0 +1,199 @@
<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

@ -0,0 +1,569 @@
<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,4 +11,7 @@ export default defineConfig({
strictPort: true,
open: `http://${pkg.name}.localhost:31337`,
},
})
test: {
environment: 'jsdom',
},
} as any)