Sie standen in einer eigenen, abgetrennten Zeile unter Laenge und Hoehenmetern, obwohl sie dasselbe sind: eine Zahl zum Trail. Jetzt eine Zeile, ganz rechts die offenen Meldungen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
255 lines
11 KiB
Svelte
255 lines
11 KiB
Svelte
<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 { trailStatus } from '$lib/trailStatus'
|
||
import { tooltip } from '$lib/stores/app.svelte'
|
||
import { getTrailCommentContext } from '$lib/stores/trailComments.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, Ruler, TrendingUp, MapPin, MessageSquare,
|
||
} from 'lucide-svelte'
|
||
import { RECENT_DAYS } from '$lib/time'
|
||
import type { TrailVersionsResponse } from '$lib/types'
|
||
|
||
const trails = getTrailContext()
|
||
const versions = getTrailVersionContext()
|
||
const markers = getTrailMarkerContext()
|
||
const comments = getTrailCommentContext()
|
||
|
||
let dialog = $state(false)
|
||
let form = $state({ name: '', description: '' })
|
||
let saving = $state(false)
|
||
let error = $state<string | null>(null)
|
||
|
||
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,
|
||
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,
|
||
unknown,
|
||
LineGeoJSON | null
|
||
> | null
|
||
}
|
||
|
||
function formatKm(m: number | undefined) {
|
||
// Nur fehlende Werte zeigen "–"; 0 ist eine gültige Länge.
|
||
if (m == null) return '–'
|
||
return `${(m / 1000).toFixed(1)} km`
|
||
}
|
||
|
||
async function save() {
|
||
if (saving) return
|
||
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}
|
||
{@const status = trailStatus(trail.status)}
|
||
{@const markerCount = markers.byTrail(trail.id).length}
|
||
{@const commentCount = comments.byTrail(trail.id).length}
|
||
{@const freshMarkers = markers.hasRecent(trail.id)}
|
||
{@const freshComments = comments.hasRecent(trail.id)}
|
||
<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>
|
||
<span
|
||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
|
||
style="color: {status.color}; border-color: {status.color}"
|
||
>
|
||
<span class="size-1.5 rounded-full" style:background={status.color}></span>
|
||
{status.label}
|
||
</span>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent class="space-y-3">
|
||
{#if version?.geojson}
|
||
<TrailMap
|
||
geojson={version.geojson}
|
||
bounds={version.bounds}
|
||
status={trail.status}
|
||
controls={false}
|
||
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}
|
||
|
||
<!--
|
||
Eine Zeile, dieselben Icons wie im Kopf der Trail-Seite:
|
||
Länge, Höhenmeter, Marker, Kommentare — und ganz rechts,
|
||
was jemanden angeht, die offenen Meldungen.
|
||
|
||
Ein Punkt markiert Bewegung der letzten zwei Wochen. Die
|
||
Zahl allein sagt nichts darüber, ob sich gerade etwas tut.
|
||
-->
|
||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||
<span class="flex items-center gap-1.5">
|
||
<Ruler class="size-4" />
|
||
{formatKm(version?.distance_m)}
|
||
</span>
|
||
<span class="flex items-center gap-1.5">
|
||
<TrendingUp class="size-4" />
|
||
{version?.ascent_m ? `${version.ascent_m} hm` : '–'}
|
||
</span>
|
||
<span
|
||
class="flex items-center gap-1.5"
|
||
class:text-foreground={freshMarkers}
|
||
class:font-medium={freshMarkers}
|
||
use:tooltip={{
|
||
content: freshMarkers
|
||
? `Marker, neue in den letzten ${RECENT_DAYS} Tagen`
|
||
: 'Marker',
|
||
}}
|
||
>
|
||
<MapPin class="size-4" />
|
||
{markerCount}
|
||
{#if freshMarkers}
|
||
<span class="size-1.5 rounded-full bg-primary"></span>
|
||
{/if}
|
||
</span>
|
||
<span
|
||
class="flex items-center gap-1.5"
|
||
class:text-foreground={freshComments}
|
||
class:font-medium={freshComments}
|
||
use:tooltip={{
|
||
content: freshComments
|
||
? `Kommentare, neue in den letzten ${RECENT_DAYS} Tagen`
|
||
: 'Kommentare',
|
||
}}
|
||
>
|
||
<MessageSquare class="size-4" />
|
||
{commentCount}
|
||
{#if freshComments}
|
||
<span class="size-1.5 rounded-full bg-primary"></span>
|
||
{/if}
|
||
</span>
|
||
{#if open > 0}
|
||
<span
|
||
class="flex items-center gap-1 text-amber-600 ml-auto"
|
||
use:tooltip={{
|
||
content: `${open} offene Meldung${open === 1 ? '' : 'en'}`,
|
||
}}
|
||
>
|
||
<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"
|
||
onkeydown={(e) => e.key === 'Enter' && save()}
|
||
/>
|
||
</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>
|