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