Laenge und Hoehenmeter tragen dieselben Icons wie der Kopf der Trail-Seite. Dazu kommen Anzahl der Marker und Kommentare - und ein Punkt daneben, wenn davon etwas aus den letzten 14 Tagen stammt. Die blosse Zahl sagt nichts darueber, ob sich gerade etwas tut. Kommentare bekommen dafuer einen eigenen Store. Sie lagen bisher direkt in der Trail-Seite, was richtig war, solange es sie nur dort gab; fuer die Liste waere ein zweiter Ladeweg auf dieselben Daten einer zu viel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
248 lines
11 KiB
Svelte
248 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 { 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}
|
||
|
||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||
<!-- Dieselben Icons wie im Kopf der Trail-Seite. -->
|
||
<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>
|
||
{#if open > 0}
|
||
<span
|
||
class="flex items-center gap-1 text-amber-600 ml-auto"
|
||
title="{open} offene Meldung{open === 1 ? '' : 'en'}"
|
||
>
|
||
<AlertTriangle class="size-4" />
|
||
{open}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
|
||
<!--
|
||
Ein Punkt markiert Bewegung der letzten zwei Wochen. Die
|
||
Zahl allein sagt nichts darüber, ob sich gerade etwas tut.
|
||
-->
|
||
<div class="flex items-center gap-4 text-sm border-t pt-3">
|
||
<span
|
||
class="flex items-center gap-1.5"
|
||
class:text-muted-foreground={!freshMarkers}
|
||
class:font-medium={freshMarkers}
|
||
title={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-muted-foreground={!freshComments}
|
||
class:font-medium={freshComments}
|
||
title={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>
|
||
</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>
|