stammtisch-hersbruck/frontend/src/routes/dashboard/trails/[id]/+page.svelte
Daniel Michelberger f8247ae3e7 feat: Trail-Seite neu geordnet, echte dunkle Karte, sichtbare Flag-Icons
Der Kopf traegt jetzt Name, Zustand und Kennzahlen: Der Status sitzt als
farbiges Dropdown direkt hinter dem Namen und nennt darunter, wann und von
wem er gemeldet wurde. Laenge und Hoehenmeter stehen mit Icons daneben.

Karte und Hoehenprofil links, Marker, Kommentare und Versionen rechts
daneben in einer Spalte. Die Reiter entfallen - alles ist gleichzeitig zu
sehen. Versionen sind Archiv und deshalb eingeklappt; der GPX-Upload sitzt
dort statt im Kopf.

Marker lassen sich jetzt auch loeschen und auch ueber das Hoehenprofil
setzen: Die angeklickte Stelle wird auf dieselbe Linie zurueckgerechnet,
die ein Klick auf der Karte trifft.

Die Flag-Icons wurden bisher gespeichert, aber nirgends gezeichnet. Sie
erscheinen jetzt in der Karte, im Popup, in der Markerliste und in der
Verwaltung. Erlaubt ist jeder Name von lucide.dev - moeglich macht das ein
Glob ueber die Icon-Dateien, der jedes Icon zu einem eigenen, erst bei
Bedarf geladenen Chunk macht. Die Liste im Dialog sind nur Vorschlaege.

Die Karte kommt von CARTO statt von OSM direkt: zurueckhaltend gezeichnet,
sodass die Trail-Linie darueber steht, und mit einer echten dunklen
Fassung. Das vorherige Abdunkeln der OSM-Kacheln ergab nur ein dunkleres
Bild, keine dunkle Karte. In der Kachelvorschau der Liste sind Zoom,
Massstab und Quellenangabe abgeschaltet - dort ist die Karte Bild, nicht
Werkzeug; die grosse Karte traegt die Angabe weiterhin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 12:56:17 +02:00

676 lines
30 KiB
Svelte

<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 FlagIcon from '$lib/components/FlagIcon.svelte'
import { positionAtDistance } from '$lib/gpx'
import { app } from '$lib/stores/app.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 DropdownMenu from '@/components/ui/dropdown-menu'
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
import {
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
Check, Trash2, Download, Plus, Ruler, TrendingUp,
} 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 — inklusive der Angaben fürs Popup
// (was, wann, wer). Formatiert wird hier, damit die Karte keine Stores
// und keine Datumslogik braucht.
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',
icon: flag?.icon,
note: m.note,
when: formatDate(m.created),
who: markers.authorName(m),
}
}),
)
let highlight = $state<number | null>(null)
// Versionen sind Archiv, kein Alltag — deshalb zugeklappt.
let showVersions = $state(false)
const statusInfo = $derived(trailStatus(trail?.status))
// Wer den Status gemeldet hat, steht nur als ID im Datensatz.
$effect(() => {
if (trail?.status_by) teams.loadUser(trail.status_by)
})
// --- 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 ---------------------------------------------------
// Die Koordinaten kommen bereits auf die Linie projiziert aus der Karte;
// abseits des Trails lässt sich gar nicht erst klicken.
let markerDialog = $state(false)
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
let markerDistance = $state<number | null>(null)
let markerFlag = $state('')
let markerNote = $state('')
let markerError = $state<string | null>(null)
const canPlace = $derived(flags.scoped.length > 0)
function onPlace(p: { lat: number; lng: number; distance: number | null }) {
if (!canPlace) return
markerCoords = { lat: p.lat, lng: p.lng }
markerDistance = p.distance
markerFlag = flags.scoped[0]?.id ?? ''
markerNote = ''
markerError = null
markerDialog = true
}
function removeMarker(m: { id: string; note?: string }) {
app.confirm.request({
title: 'Marker löschen?',
text: m.note
? `„${m.note}" wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.`
: 'Der Marker wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.',
yes: async () => {
try {
await markers.remove(m.id)
} catch (e: any) {
alert(e.message ?? 'Der Marker konnte nicht gelöscht werden.')
} finally {
app.confirm.close()
}
},
no: () => app.confirm.close(),
})
}
/**
* Marker aus dem Höhenprofil heraus. Die Stelle kommt als Streckenkilometer
* und wird auf dieselbe Linie zurückgerechnet, die auch ein Klick auf der
* Karte trifft — beide Wege landen deshalb exakt auf dem Trail.
*/
function placeFromProfile(distance: number) {
const coords = version?.geojson?.coordinates
if (!coords?.length) return
const at = positionAtDistance(coords, version?.coord_distances ?? [], distance)
if (!at) return
onPlace({ lng: at[0], lat: at[1], distance })
}
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
markerDistance = 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 -------------------------------------------------
function formatDate(s: string) {
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
}
async function setStatus(status: string) {
if (!trail || status === trail.status) return
try {
await trails.setStatus(trail.id, status)
} catch (e: any) {
alert(e.message ?? 'Der Zustand konnte nicht gemeldet werden.')
}
}
</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="space-y-2">
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" />
Trails
</Button>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<h1 class="text-4xl font-bold tracking-tight">{trail.name}</h1>
{#if canEdit}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<!-- Farbe kommt aus derselben Quelle wie die Linie auf der Karte. -->
<Button
{...props}
variant="outline"
size="sm"
class="gap-2"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
<ChevronDown class="size-3 opacity-60" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-60">
<DropdownMenu.Label>Zustand melden</DropdownMenu.Label>
{#each TRAIL_STATUS as s (s.value)}
<DropdownMenu.Item
onclick={() => setStatus(s.value)}
class={s.value === trail.status ? 'bg-accent' : ''}
>
<span class="size-2 rounded-full mr-2 shrink-0" style:background={s.color}></span>
<span class="flex-1">{s.label}</span>
<span class="text-xs text-muted-foreground">{s.hint}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<span
class="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
</span>
{/if}
{#if version}
<span class="flex items-center gap-4 text-sm text-muted-foreground">
<span class="flex items-center gap-1.5">
<Ruler class="size-4" />
<strong class="text-foreground">{(version.distance_m / 1000).toFixed(1)}</strong> km
</span>
<span class="flex items-center gap-1.5">
<TrendingUp class="size-4" />
<strong class="text-foreground">{version.ascent_m}</strong> hm
</span>
</span>
{/if}
</div>
{#if trail.status_changed}
<p class="text-xs text-muted-foreground">
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
gemeldet am {formatDate(trail.status_changed)}
{#if trail.status_by}
von {teams.userLabel(trail.status_by)}
{/if}
</p>
{/if}
{#if safeDescription}
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
{/if}
</div>
<div class="grid gap-6 lg:grid-cols-3 items-start">
<!-- Karte und Höhenprofil -->
<Card class="lg:col-span-2">
<CardContent class="pt-6 space-y-4">
{#if version?.geojson}
<TrailMap
geojson={version.geojson}
bounds={version.bounds}
markers={mapMarkers}
status={trail.status}
coordDistances={version.coord_distances ?? []}
highlightDistance={highlight}
onplace={canPlace ? onPlace : undefined}
ontrailhover={(d) => (highlight = d)}
/>
{#if version.elevation?.length}
<ElevationProfile
elevation={version.elevation}
highlightDistance={highlight}
onhover={(d) => (highlight = d)}
onselect={canPlace ? placeFromProfile : undefined}
/>
{/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>
<!-- Marker, Kommentare, Versionen -->
<div class="space-y-4">
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MapPin class="size-4 text-primary" />
Marker ({trailMarkers.length})
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
{#if canPlace}
<p class="text-xs text-muted-foreground">
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
Marker zu setzen.
</p>
{:else}
<p class="text-xs text-muted-foreground">
Es gibt noch keine Flag-Typen.
<a href="/dashboard/settings/flags" class="underline">Jetzt anlegen</a>
</p>
{/if}
{#if trailMarkers.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
{:else}
<ul class="divide-y -mx-2">
{#each trailMarkers as m (m.id)}
{@const flag = flags.getById(m.flag)}
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
<FlagIcon name={flag?.icon} color={flag?.color ?? '#64748b'} class="size-5" />
<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)} · {markers.authorName(m)}
</p>
</div>
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
<div class="flex shrink-0">
<Button
variant="ghost"
size="icon"
class="size-8"
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>
<Button
variant="ghost"
size="icon"
class="size-8"
title="Marker löschen"
onclick={() => removeMarker(m)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MessageSquare class="size-4 text-primary" />
Kommentare ({comments.length})
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<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-sm text-muted-foreground">Noch keine Kommentare.</p>
{:else}
<ul class="divide-y -mx-2">
{#each comments as c (c.id)}
<li class="px-2 py-3">
<p class="text-sm">{c.text}</p>
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
<Card>
<!-- Versionen sind Archiv: zugeklappt, bis jemand sie braucht. -->
<button
type="button"
class="w-full flex items-center gap-2 px-6 py-4 text-left"
aria-expanded={showVersions}
onclick={() => (showVersions = !showVersions)}
>
<History class="size-4 text-primary" />
<span class="font-semibold text-base flex-1">
Versionen ({trailVersions.length})
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform"
style={showVersions ? 'transform: rotate(180deg)' : ''}
/>
</button>
{#if showVersions}
<CardContent class="pt-0 space-y-3">
{#if canEdit}
<Button variant="outline" size="sm" class="w-full" onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
{#if trailVersions.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Version hochgeladen.</p>
{:else}
<ul class="divide-y -mx-2">
{#each trailVersions as v (v.id)}
<li class="px-2 py-3 flex items-start gap-2">
<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-xs 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="icon" class="size-8" 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}
</li>
{/each}
</ul>
{/if}
</CardContent>
{/if}
</Card>
</div>
</div>
</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>
{#if markerDistance != null}
<Dialog.Description>
bei km {(markerDistance / 1000).toFixed(2)} des Trails
</Dialog.Description>
{/if}
</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>