Ein Datum muss man erst gegen den heutigen Tag rechnen, um zu wissen, ob eine Sperrung von gestern stammt oder vom letzten Sommer — der Abstand sagt es sofort. Das genaue Datum steht daneben im Tooltip und geht dadurch nicht verloren. date-fns lag bereits im Projekt, es brauchte keine neue Library. parseDate nimmt PocketBases "2026-09-12 08:30:00.000Z" mit Leerzeichen statt T; die meisten Browser schlucken das, garantiert ist es nicht. formatDate auf der Trail-Seite war eine Kopie von formatDateTime und kommt jetzt aus $lib/time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
716 lines
32 KiB
Svelte
716 lines
32 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 { getTrailCommentContext } from '$lib/stores/trailComments.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 IconPicker from '$lib/components/IconPicker.svelte'
|
|
import Avatar from '$lib/components/Avatar.svelte'
|
|
import { tooltip } from '$lib/stores/app.svelte'
|
|
import { formatDateTime, formatRelative } from '$lib/time'
|
|
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 * as Select from '@/components/ui/select'
|
|
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 { 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 commentStore = getTrailCommentContext()
|
|
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) : [])
|
|
|
|
const NEUTRAL = '#64748b'
|
|
|
|
/**
|
|
* Beschriftung, Icon und Farbe eines Markers. Ein eigener Text oder ein
|
|
* eigenes Icon am Marker geht vor: Beim Setzen darf der Typ nur den
|
|
* Vorschlag liefern, nicht das letzte Wort haben.
|
|
*/
|
|
function markerLook(m: { flag?: string; label?: string; icon?: string }) {
|
|
const flag = m.flag ? flags.getById(m.flag) : undefined
|
|
|
|
return {
|
|
label: m.label?.trim() || flag?.label || 'Marker',
|
|
icon: m.icon?.trim() || flag?.icon,
|
|
color: flag?.color ?? NEUTRAL,
|
|
}
|
|
}
|
|
|
|
// 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 look = markerLook(m)
|
|
return {
|
|
id: m.id,
|
|
lat: m.lat,
|
|
lng: m.lng,
|
|
color: look.color,
|
|
resolved: !!m.resolved,
|
|
label: look.label,
|
|
icon: look.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)
|
|
// Leerer Wert = eigener Typ. Als erste Zeile im Dropdown, damit eine
|
|
// Meldung nie daran scheitert, dass es den passenden Typ noch nicht gibt.
|
|
const CUSTOM = ''
|
|
|
|
let markerFlag = $state(CUSTOM)
|
|
let markerLabel = $state('')
|
|
let markerIcon = $state('')
|
|
let markerNote = $state('')
|
|
let markerError = $state<string | null>(null)
|
|
|
|
// Ohne Flag-Typen geht es trotzdem: Dann ist jede Meldung eine eigene.
|
|
const canPlace = $derived(!!trail)
|
|
|
|
const selectedFlag = $derived(markerFlag ? flags.getById(markerFlag) : undefined)
|
|
const markerColor = $derived(selectedFlag?.color ?? NEUTRAL)
|
|
|
|
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 ?? CUSTOM
|
|
markerLabel = ''
|
|
markerIcon = flags.scoped[0]?.icon ?? ''
|
|
markerNote = ''
|
|
markerError = null
|
|
markerDialog = true
|
|
}
|
|
|
|
/**
|
|
* Typwechsel übernimmt dessen Icon — als Vorschlag. Wer danach ein anderes
|
|
* wählt, behält es; erst der nächste Typwechsel schlägt wieder etwas vor.
|
|
*/
|
|
function selectFlag(id: string) {
|
|
markerFlag = id
|
|
markerIcon = id ? (flags.getById(id)?.icon ?? '') : ''
|
|
}
|
|
|
|
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 && !markerLabel.trim()) {
|
|
markerError = 'Bitte einen Typ wählen oder eine eigene Bezeichnung eintragen.'
|
|
return
|
|
}
|
|
|
|
try {
|
|
await markers.create({
|
|
trail: trail.id,
|
|
flag: markerFlag,
|
|
// Bei einem vorgefertigten Typ steht die Bezeichnung schon dort;
|
|
// sie hier zu kopieren hieße, sie zweimal zu pflegen.
|
|
label: markerFlag ? '' : markerLabel.trim(),
|
|
icon: markerIcon.trim(),
|
|
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 ------------------------------------------------------
|
|
const comments = $derived(trail ? commentStore.byTrail(trail.id) : [])
|
|
|
|
let commentText = $state('')
|
|
let commentError = $state<string | null>(null)
|
|
|
|
async function addComment() {
|
|
if (!trail || !commentText.trim()) return
|
|
|
|
commentError = null
|
|
try {
|
|
await commentStore.create(trail.id, trail.team, commentText)
|
|
commentText = ''
|
|
} catch (e: any) {
|
|
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'
|
|
}
|
|
}
|
|
|
|
// --- Hilfsfunktionen -------------------------------------------------
|
|
// Dieselbe Formatierung stand hier als eigene Zeile Code und in
|
|
// $lib/time; jetzt kommt sie nur noch von dort.
|
|
const formatDate = formatDateTime
|
|
|
|
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}
|
|
<!--
|
|
Relativ, weil am Zustand die Frage „wie alt ist die
|
|
Meldung?" haengt und nicht „welcher Tag war das?". Das
|
|
genaue Datum steht im Tooltip.
|
|
-->
|
|
<p class="text-xs text-muted-foreground">
|
|
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
|
|
gemeldet
|
|
<span use:tooltip={{ content: formatDateTime(trail.status_changed) }}>
|
|
{formatRelative(trail.status_changed)}
|
|
</span>
|
|
{#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">
|
|
<p class="text-xs text-muted-foreground">
|
|
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
|
|
Marker zu setzen.
|
|
</p>
|
|
|
|
{#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 look = markerLook(m)}
|
|
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
|
|
<FlagIcon name={look.icon} color={look.color} class="size-5" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium">{look.label}</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"
|
|
tooltip={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"
|
|
tooltip="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)}
|
|
{@const author = c.expand?.created_by}
|
|
<li class="flex gap-3 px-2 py-3">
|
|
<!-- Nur das Bild; wer dahintersteckt, sagt der Tooltip. -->
|
|
<span
|
|
use:tooltip={{
|
|
content: author?.name || author?.email || 'Unbekannt',
|
|
}}
|
|
>
|
|
<Avatar user={author} size={28} />
|
|
</span>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm">{c.text}</p>
|
|
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
|
</div>
|
|
</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)} tooltip="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>
|
|
<Select.Root type="single" value={markerFlag} onValueChange={selectFlag}>
|
|
<Select.Trigger class="w-full">
|
|
{#if selectedFlag}
|
|
<span class="flex items-center gap-2">
|
|
<span class="size-2 rounded-full" style:background={selectedFlag.color}></span>
|
|
{selectedFlag.label}
|
|
</span>
|
|
{:else}
|
|
Eigener Typ
|
|
{/if}
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
<Select.Item value={CUSTOM}>Eigener Typ …</Select.Item>
|
|
{#each flags.scoped as f (f.id)}
|
|
<Select.Item value={f.id}>
|
|
<span class="size-2 rounded-full mr-2 shrink-0" style:background={f.color}></span>
|
|
{f.label}
|
|
</Select.Item>
|
|
{/each}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
</div>
|
|
|
|
{#if !markerFlag}
|
|
<div class="space-y-2">
|
|
<Label for="marker-label">Bezeichnung</Label>
|
|
<Input id="marker-label" bind:value={markerLabel} placeholder="z. B. Wespennest am Anlieger" />
|
|
</div>
|
|
{/if}
|
|
|
|
<IconPicker id="marker-icon" bind:value={markerIcon} color={markerColor} />
|
|
<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>
|