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
676 lines
30 KiB
Svelte
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>
|