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:
parent
a0ccafaa91
commit
60d5891bc9
1 changed files with 190 additions and 0 deletions
190
frontend/src/routes/dashboard/trails/+page.svelte
Normal file
190
frontend/src/routes/dashboard/trails/+page.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue