feat: Trail-Detailseite mit Karte, Höhenprofil, Markern und Kommentaren
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7bc128601a
commit
a45c5d2908
1 changed files with 543 additions and 0 deletions
543
frontend/src/routes/dashboard/trails/[id]/+page.svelte
Normal file
543
frontend/src/routes/dashboard/trails/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
<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 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 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,
|
||||
Elevation | null,
|
||||
LineGeoJSON | null
|
||||
>
|
||||
|
||||
const trails = getTrailContext()
|
||||
const versions = getTrailVersionContext()
|
||||
const flags = getTrailFlagContext()
|
||||
const markers = getTrailMarkerContext()
|
||||
|
||||
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
|
||||
const canEdit = $derived(trail ? trails.canEdit(trail) : false)
|
||||
|
||||
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 trail.description}
|
||||
<p class="text-muted-foreground text-sm">{@html trail.description}</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}
|
||||
<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}
|
||||
highlightIndex={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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||
onclick={() => markers.toggleResolved(m.id)}
|
||||
>
|
||||
<Check class="size-4" />
|
||||
</Button>
|
||||
</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>
|
||||
Loading…
Reference in a new issue