feat: Trail-Liste mit Vorschaukarten

Übersichtsseite zeigt alle Trails des Teams als Grid mit Kartvorschau,
Status-Badge und Kennzahlen. Dialog für Trail-Erstellung mit GPX-Upload
im nächsten Schritt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Michelberger 2026-08-06 18:27:33 +02:00
parent a0ccafaa91
commit 60d5891bc9

View file

@ -0,0 +1,190 @@
<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,
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,
LineGeoJSON | null
> | null
}
function formatKm(m: number | undefined) {
if (!m) return ''
return `${(m / 1000).toFixed(1)} km`
}
async function save() {
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" />
</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>