Compare commits
30 commits
f316058367
...
1257456308
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1257456308 | ||
|
|
ccc1d931ab | ||
|
|
331c4cfed1 | ||
|
|
7b1df79a37 | ||
|
|
08af521d19 | ||
|
|
c44ec4377f | ||
|
|
8e256d0184 | ||
|
|
a45c5d2908 | ||
|
|
7bc128601a | ||
|
|
60d5891bc9 | ||
|
|
a0ccafaa91 | ||
|
|
b1f6f8d1e5 | ||
|
|
060c4434b9 | ||
|
|
616694a7f5 | ||
|
|
f432b47df1 | ||
|
|
6fd459e938 | ||
|
|
247ac8ec6b | ||
|
|
efadbab48a | ||
|
|
1eb25cd7e4 | ||
|
|
f89f163e22 | ||
|
|
ef8c05bd7c | ||
|
|
64c5139d71 | ||
|
|
f576550429 | ||
|
|
55f25fae0c | ||
|
|
9948073b25 | ||
|
|
c41a6cb056 | ||
|
|
ced89ed9d2 | ||
|
|
2e8f92b6f6 | ||
|
|
564632522e | ||
|
|
f11f7b4dff |
25 changed files with 8231 additions and 40 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -33,3 +33,4 @@ backups/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
/frontend/TODO.md
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
271
backend/pb_migrations/1754500000_created_trails.js
Normal file
271
backend/pb_migrations/1754500000_created_trails.js
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
17
backend/pb_migrations/1754500100_users_avatar_maxsize.js
Normal file
17
backend/pb_migrations/1754500100_users_avatar_maxsize.js
Normal 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)
|
||||||
|
})
|
||||||
26
backend/pb_migrations/1754500200_trail_flags_delete_rule.js
Normal file
26
backend/pb_migrations/1754500200_trail_flags_delete_rule.js
Normal 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)
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
3189
docs/superpowers/plans/2026-08-06-trails.md
Normal file
3189
docs/superpowers/plans/2026-08-06-trails.md
Normal file
File diff suppressed because it is too large
Load diff
318
docs/superpowers/specs/2026-08-06-trails-design.md
Normal file
318
docs/superpowers/specs/2026-08-06-trails-design.md
Normal 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`)
|
||||||
1167
frontend/package-lock.json
generated
1167
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,8 @@
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"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"
|
"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": {
|
"devDependencies": {
|
||||||
|
|
@ -26,6 +28,7 @@
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
"bits-ui": "^2.18.1",
|
"bits-ui": "^2.18.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"pocketbase-typegen": "^1.3.1",
|
"pocketbase-typegen": "^1.3.1",
|
||||||
"svelte": "^5.39.5",
|
"svelte": "^5.39.5",
|
||||||
"svelte-check": "^4.3.2",
|
"svelte-check": "^4.3.2",
|
||||||
|
|
@ -34,11 +37,14 @@
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.4.13",
|
||||||
"lucide-svelte": "^0.503.0",
|
"lucide-svelte": "^0.503.0",
|
||||||
|
"maplibre-gl": "^6.2.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"pocketbase": "^0.26.0",
|
"pocketbase": "^0.26.0",
|
||||||
"tippy.js": "^6.3.7"
|
"tippy.js": "^6.3.7"
|
||||||
|
|
|
||||||
93
frontend/src/lib/components/ElevationProfile.svelte
Normal file
93
frontend/src/lib/components/ElevationProfile.svelte
Normal 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}
|
||||||
259
frontend/src/lib/components/TrailMap.svelte
Normal file
259
frontend/src/lib/components/TrailMap.svelte
Normal 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 = '© 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}
|
||||||
191
frontend/src/lib/gpx.test.ts
Normal file
191
frontend/src/lib/gpx.test.ts
Normal 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
224
frontend/src/lib/gpx.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
152
frontend/src/lib/stores/trailFlags.svelte.ts
Normal file
152
frontend/src/lib/stores/trailFlags.svelte.ts
Normal 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)
|
||||||
|
}
|
||||||
116
frontend/src/lib/stores/trailMarkers.svelte.ts
Normal file
116
frontend/src/lib/stores/trailMarkers.svelte.ts
Normal 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)
|
||||||
|
}
|
||||||
121
frontend/src/lib/stores/trailVersions.svelte.ts
Normal file
121
frontend/src/lib/stores/trailVersions.svelte.ts
Normal 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)
|
||||||
|
}
|
||||||
119
frontend/src/lib/stores/trails.svelte.ts
Normal file
119
frontend/src/lib/stores/trails.svelte.ts
Normal 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)
|
||||||
|
}
|
||||||
100
frontend/src/lib/types.d.ts
vendored
100
frontend/src/lib/types.d.ts
vendored
|
|
@ -11,6 +11,11 @@ export enum Collections {
|
||||||
Runs = "runs",
|
Runs = "runs",
|
||||||
Teams = "teams",
|
Teams = "teams",
|
||||||
Times = "times",
|
Times = "times",
|
||||||
|
TrailComments = "trail_comments",
|
||||||
|
TrailFlags = "trail_flags",
|
||||||
|
TrailMarkers = "trail_markers",
|
||||||
|
TrailVersions = "trail_versions",
|
||||||
|
Trails = "trails",
|
||||||
Users = "users",
|
Users = "users",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,6 +118,81 @@ export type TimesRecord = {
|
||||||
updated?: IsoDateString
|
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 = {
|
export type UsersRecord = {
|
||||||
avatar?: string
|
avatar?: string
|
||||||
created?: IsoDateString
|
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 RunsResponse<Texpand = unknown> = Required<RunsRecord> & BaseSystemFields<Texpand>
|
||||||
export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
|
export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
|
||||||
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & 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>
|
export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSystemFields<Texpand>
|
||||||
|
|
||||||
// Types containing all Records and Responses, useful for creating typing helper functions
|
// Types containing all Records and Responses, useful for creating typing helper functions
|
||||||
|
|
@ -143,6 +228,11 @@ export type CollectionRecords = {
|
||||||
runs: RunsRecord
|
runs: RunsRecord
|
||||||
teams: TeamsRecord
|
teams: TeamsRecord
|
||||||
times: TimesRecord
|
times: TimesRecord
|
||||||
|
trail_comments: TrailCommentsRecord
|
||||||
|
trail_flags: TrailFlagsRecord
|
||||||
|
trail_markers: TrailMarkersRecord
|
||||||
|
trail_versions: TrailVersionsRecord
|
||||||
|
trails: TrailsRecord
|
||||||
users: UsersRecord
|
users: UsersRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,6 +242,11 @@ export type CollectionResponses = {
|
||||||
runs: RunsResponse
|
runs: RunsResponse
|
||||||
teams: TeamsResponse
|
teams: TeamsResponse
|
||||||
times: TimesResponse
|
times: TimesResponse
|
||||||
|
trail_comments: TrailCommentsResponse
|
||||||
|
trail_flags: TrailFlagsResponse
|
||||||
|
trail_markers: TrailMarkersResponse
|
||||||
|
trail_versions: TrailVersionsResponse
|
||||||
|
trails: TrailsResponse
|
||||||
users: UsersResponse
|
users: UsersResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,5 +259,10 @@ export type TypedPocketBase = PocketBase & {
|
||||||
collection(idOrName: 'runs'): RecordService<RunsResponse>
|
collection(idOrName: 'runs'): RecordService<RunsResponse>
|
||||||
collection(idOrName: 'teams'): RecordService<TeamsResponse>
|
collection(idOrName: 'teams'): RecordService<TeamsResponse>
|
||||||
collection(idOrName: 'times'): RecordService<TimesResponse>
|
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>
|
collection(idOrName: 'users'): RecordService<UsersResponse>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@
|
||||||
import { setRunContext } from '$lib/stores/runs.svelte'
|
import { setRunContext } from '$lib/stores/runs.svelte'
|
||||||
import { setRiderContext } from '$lib/stores/riders.svelte'
|
import { setRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { setTimeContext } from '$lib/stores/times.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 { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import Logo from '$lib/components/Logo.svelte'
|
import Logo from '$lib/components/Logo.svelte'
|
||||||
import TeamSwitcher from '$lib/components/TeamSwitcher.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 { toggleMode, mode } from 'mode-watcher'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
|
||||||
|
|
@ -24,6 +28,10 @@
|
||||||
const runs = setRunContext()
|
const runs = setRunContext()
|
||||||
const riders = setRiderContext()
|
const riders = setRiderContext()
|
||||||
const times = setTimeContext()
|
const times = setTimeContext()
|
||||||
|
const trails = setTrailContext()
|
||||||
|
const trailVersions = setTrailVersionContext()
|
||||||
|
const trailFlags = setTrailFlagContext()
|
||||||
|
const trailMarkers = setTrailMarkerContext()
|
||||||
|
|
||||||
let isMobileMenuOpen = $state(false)
|
let isMobileMenuOpen = $state(false)
|
||||||
|
|
||||||
|
|
@ -35,11 +43,18 @@
|
||||||
// Teams zuerst laden, weil die anderen Stores nach team filtern
|
// Teams zuerst laden, weil die anderen Stores nach team filtern
|
||||||
await teams.load()
|
await teams.load()
|
||||||
teams.subscribe()
|
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()
|
events.subscribe()
|
||||||
runs.subscribe()
|
runs.subscribe()
|
||||||
riders.subscribe()
|
riders.subscribe()
|
||||||
times.subscribe()
|
times.subscribe()
|
||||||
|
trails.subscribe()
|
||||||
|
trailVersions.subscribe()
|
||||||
|
trailFlags.subscribe()
|
||||||
|
trailMarkers.subscribe()
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|
@ -48,6 +63,10 @@
|
||||||
runs.destroy()
|
runs.destroy()
|
||||||
riders.destroy()
|
riders.destroy()
|
||||||
times.destroy()
|
times.destroy()
|
||||||
|
trails.destroy()
|
||||||
|
trailVersions.destroy()
|
||||||
|
trailFlags.destroy()
|
||||||
|
trailMarkers.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
|
|
@ -66,8 +85,10 @@
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
||||||
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
||||||
|
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
|
||||||
{ href: '/dashboard/timing', icon: Timer, label: 'Zeitnahme' },
|
{ href: '/dashboard/timing', icon: Timer, label: 'Zeitnahme' },
|
||||||
{ href: '/dashboard/results', icon: Flag, label: 'Ergebnisse' },
|
{ href: '/dashboard/results', icon: Flag, label: 'Ergebnisse' },
|
||||||
|
{ href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -76,9 +97,18 @@
|
||||||
<header class="border-b bg-card">
|
<header class="border-b bg-card">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<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 h-16 items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2">
|
||||||
<Logo />
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav class="hidden md:flex items-center gap-1">
|
<nav class="hidden md:flex items-center gap-1">
|
||||||
|
|
@ -95,19 +125,7 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<div class="hidden md:flex items-center gap-2">
|
||||||
<Separator orientation="vertical" class="h-6" />
|
|
||||||
<div class="text-sm text-right">
|
<div class="text-sm text-right">
|
||||||
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
|
<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>
|
<p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>
|
||||||
|
|
|
||||||
221
frontend/src/routes/dashboard/settings/flags/+page.svelte
Normal file
221
frontend/src/routes/dashboard/settings/flags/+page.svelte
Normal 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>
|
||||||
199
frontend/src/routes/dashboard/trails/+page.svelte
Normal file
199
frontend/src/routes/dashboard/trails/+page.svelte
Normal 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>
|
||||||
569
frontend/src/routes/dashboard/trails/[id]/+page.svelte
Normal file
569
frontend/src/routes/dashboard/trails/[id]/+page.svelte
Normal 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>
|
||||||
|
|
@ -11,4 +11,7 @@ export default defineConfig({
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
open: `http://${pkg.name}.localhost:31337`,
|
open: `http://${pkg.name}.localhost:31337`,
|
||||||
},
|
},
|
||||||
})
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
Loading…
Reference in a new issue