feat: Trail-Detailseite mit Karte, Höhenprofil, Markern und Kommentaren
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7bc128601a
commit
a45c5d2908
1 changed files with 543 additions and 0 deletions
543
frontend/src/routes/dashboard/trails/[id]/+page.svelte
Normal file
543
frontend/src/routes/dashboard/trails/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { api, auth, getFileURL } from '$lib/stores/pocketbase.svelte'
|
||||||
|
import { getTrailContext } from '$lib/stores/trails.svelte'
|
||||||
|
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
||||||
|
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
||||||
|
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||||
|
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||||
|
import ElevationProfile from '$lib/components/ElevationProfile.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 * as Select from '@/components/ui/select'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import {
|
||||||
|
ArrowLeft, Upload, MapPin, MessageSquare, History,
|
||||||
|
Check, Trash2, Download, Plus, AlertTriangle,
|
||||||
|
} from 'lucide-svelte'
|
||||||
|
import type { TrailCommentsResponse, TrailVersionsResponse } from '$lib/types'
|
||||||
|
|
||||||
|
// Generics der JSON-Felder, wie in trails/+page.svelte — die
|
||||||
|
// Rohdaten kommen ungetypt aus PocketBase zurück.
|
||||||
|
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
|
||||||
|
type Elevation = { d: number; ele: number }[]
|
||||||
|
type Version = TrailVersionsResponse<
|
||||||
|
[[number, number], [number, number]] | null,
|
||||||
|
Elevation | null,
|
||||||
|
LineGeoJSON | null
|
||||||
|
>
|
||||||
|
|
||||||
|
const trails = getTrailContext()
|
||||||
|
const versions = getTrailVersionContext()
|
||||||
|
const flags = getTrailFlagContext()
|
||||||
|
const markers = getTrailMarkerContext()
|
||||||
|
|
||||||
|
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
|
||||||
|
const canEdit = $derived(trail ? trails.canEdit(trail) : false)
|
||||||
|
|
||||||
|
const version = $derived.by((): Version | null => {
|
||||||
|
if (!trail?.current) return null
|
||||||
|
return (versions.records.find((v) => v.id === trail.current) ?? null) as Version | null
|
||||||
|
})
|
||||||
|
|
||||||
|
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
|
||||||
|
const trailMarkers = $derived(trail ? markers.byTrail(trail.id) : [])
|
||||||
|
|
||||||
|
// Für die Karte aufbereitete Marker
|
||||||
|
const mapMarkers = $derived(
|
||||||
|
trailMarkers.map((m) => {
|
||||||
|
const flag = flags.getById(m.flag)
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
lat: m.lat,
|
||||||
|
lng: m.lng,
|
||||||
|
color: flag?.color ?? '#64748b',
|
||||||
|
resolved: !!m.resolved,
|
||||||
|
label: flag?.label ?? 'Marker',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
let tab = $state<'marker' | 'kommentare' | 'versionen'>('marker')
|
||||||
|
let highlight = $state<number | null>(null)
|
||||||
|
|
||||||
|
// --- GPX-Upload ------------------------------------------------------
|
||||||
|
let uploadDialog = $state(false)
|
||||||
|
let uploadFile = $state<File | null>(null)
|
||||||
|
let uploadNote = $state('')
|
||||||
|
let uploading = $state(false)
|
||||||
|
let uploadError = $state<string | null>(null)
|
||||||
|
|
||||||
|
async function doUpload() {
|
||||||
|
if (!trail || !uploadFile) return
|
||||||
|
|
||||||
|
uploading = true
|
||||||
|
uploadError = null
|
||||||
|
try {
|
||||||
|
await versions.upload(trail.id, uploadFile, uploadNote)
|
||||||
|
uploadDialog = false
|
||||||
|
uploadFile = null
|
||||||
|
uploadNote = ''
|
||||||
|
} catch (e: any) {
|
||||||
|
// parseGpx wirft mit verständlichem Text — direkt zeigen. Der
|
||||||
|
// Upload läuft in zwei Schritten (Version anlegen, dann aktivieren);
|
||||||
|
// scheitert der zweite, existiert die Version schon.
|
||||||
|
uploadError = (e.message ?? 'Die Datei konnte nicht verarbeitet werden.')
|
||||||
|
+ ' Prüfe unter „Versionen“, ob sie trotzdem angelegt wurde.'
|
||||||
|
} finally {
|
||||||
|
uploading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Marker setzen ---------------------------------------------------
|
||||||
|
let placing = $state(false)
|
||||||
|
let markerDialog = $state(false)
|
||||||
|
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
|
||||||
|
let markerFlag = $state('')
|
||||||
|
let markerNote = $state('')
|
||||||
|
let markerError = $state<string | null>(null)
|
||||||
|
|
||||||
|
function onPlace(coords: { lat: number; lng: number }) {
|
||||||
|
markerCoords = coords
|
||||||
|
markerFlag = flags.scoped[0]?.id ?? ''
|
||||||
|
markerNote = ''
|
||||||
|
markerError = null
|
||||||
|
placing = false
|
||||||
|
markerDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMarker() {
|
||||||
|
if (!trail || !markerCoords) return
|
||||||
|
if (!markerFlag) {
|
||||||
|
markerError = 'Bitte einen Flag-Typ wählen.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markers.create({
|
||||||
|
trail: trail.id,
|
||||||
|
flag: markerFlag,
|
||||||
|
lat: markerCoords.lat,
|
||||||
|
lng: markerCoords.lng,
|
||||||
|
note: markerNote,
|
||||||
|
})
|
||||||
|
markerDialog = false
|
||||||
|
markerCoords = null
|
||||||
|
} catch (e: any) {
|
||||||
|
markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kommentare ------------------------------------------------------
|
||||||
|
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
|
||||||
|
let comments = $state<TrailCommentsResponse[]>([])
|
||||||
|
let commentText = $state('')
|
||||||
|
let commentError = $state<string | null>(null)
|
||||||
|
let unsubComments: (() => void) | null = null
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = trail?.id
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
api.collection('trail_comments')
|
||||||
|
.getFullList({ filter: `trail="${id}"`, sort: '-created', requestKey: null })
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) comments = r
|
||||||
|
})
|
||||||
|
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
|
||||||
|
|
||||||
|
api.collection('trail_comments')
|
||||||
|
.subscribe('*', (e) => {
|
||||||
|
if (e.record.trail !== id) return
|
||||||
|
const idx = comments.findIndex((c) => c.id === e.record.id)
|
||||||
|
if (e.action === 'create' && idx === -1) comments = [e.record, ...comments]
|
||||||
|
else if (e.action === 'update' && idx !== -1) comments[idx] = e.record
|
||||||
|
else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== e.record.id)
|
||||||
|
})
|
||||||
|
.then((u) => {
|
||||||
|
if (cancelled) u()
|
||||||
|
else unsubComments = u
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn('trail_comments subscribe failed:', e))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
unsubComments?.()
|
||||||
|
unsubComments = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function addComment() {
|
||||||
|
if (!trail || !commentText.trim()) return
|
||||||
|
|
||||||
|
commentError = null
|
||||||
|
try {
|
||||||
|
await api.collection('trail_comments').create({
|
||||||
|
trail: trail.id,
|
||||||
|
team: trail.team,
|
||||||
|
text: commentText.trim(),
|
||||||
|
created_by: auth.user?.id,
|
||||||
|
})
|
||||||
|
commentText = ''
|
||||||
|
} catch (e: any) {
|
||||||
|
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hilfsfunktionen -------------------------------------------------
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
offen: 'Offen',
|
||||||
|
eingeschraenkt: 'Eingeschränkt',
|
||||||
|
gesperrt: 'Gesperrt',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const STATUS_VARIANT = {
|
||||||
|
offen: 'default',
|
||||||
|
eingeschraenkt: 'secondary',
|
||||||
|
gesperrt: 'destructive',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function formatDate(s: string) {
|
||||||
|
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStatus(status: string) {
|
||||||
|
if (!trail) return
|
||||||
|
await trails.edit(trail.id, { status } as any)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !trail}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Button variant="ghost" onclick={() => goto('/dashboard/trails')}>
|
||||||
|
<ArrowLeft class="size-4 mr-2" />
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<p class="text-muted-foreground">Trail nicht gefunden.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Kopf -->
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
|
||||||
|
<ArrowLeft class="size-4 mr-2" />
|
||||||
|
Trails
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-2xl font-semibold">{trail.name}</h1>
|
||||||
|
{#if trail.description}
|
||||||
|
<p class="text-muted-foreground text-sm">{@html trail.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
|
||||||
|
{STATUS_LABEL[trail.status] ?? trail.status}
|
||||||
|
</Badge>
|
||||||
|
{#if canEdit}
|
||||||
|
<Button variant="outline" size="sm" onclick={() => (uploadDialog = true)}>
|
||||||
|
<Upload class="size-4 mr-2" />
|
||||||
|
GPX hochladen
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if canEdit}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#each ['offen', 'eingeschraenkt', 'gesperrt'] as s}
|
||||||
|
<Button
|
||||||
|
variant={trail.status === s ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => setStatus(s)}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[s as keyof typeof STATUS_LABEL]}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Karte -->
|
||||||
|
<Card>
|
||||||
|
<CardContent class="pt-6 space-y-4">
|
||||||
|
{#if version?.geojson}
|
||||||
|
<TrailMap
|
||||||
|
geojson={version.geojson}
|
||||||
|
bounds={version.bounds}
|
||||||
|
markers={mapMarkers}
|
||||||
|
status={trail.status}
|
||||||
|
{placing}
|
||||||
|
highlightIndex={highlight}
|
||||||
|
onplace={onPlace}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-6 text-sm">
|
||||||
|
<span><strong>{(version.distance_m / 1000).toFixed(1)}</strong> km</span>
|
||||||
|
<span><strong>{version.ascent_m}</strong> hm</span>
|
||||||
|
<Button
|
||||||
|
variant={placing ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
class="ml-auto"
|
||||||
|
onclick={() => (placing = !placing)}
|
||||||
|
disabled={flags.scoped.length === 0}
|
||||||
|
>
|
||||||
|
<MapPin class="size-4 mr-2" />
|
||||||
|
{placing ? 'Abbrechen' : 'Marker setzen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if flags.scoped.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Es gibt noch keine Flag-Typen.
|
||||||
|
<a href="/dashboard/settings/flags" class="underline">Jetzt anlegen</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if version.elevation?.length}
|
||||||
|
<Separator />
|
||||||
|
<ElevationProfile
|
||||||
|
elevation={version.elevation}
|
||||||
|
onhover={(i) => (highlight = i)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="py-12 text-center space-y-3">
|
||||||
|
<Upload class="size-10 mx-auto text-muted-foreground" />
|
||||||
|
<p class="text-muted-foreground">Für diesen Trail gibt es noch keine GPX-Datei.</p>
|
||||||
|
{#if canEdit}
|
||||||
|
<Button onclick={() => (uploadDialog = true)}>
|
||||||
|
<Upload class="size-4 mr-2" />
|
||||||
|
GPX hochladen
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Reiter -->
|
||||||
|
<div class="flex gap-2 border-b">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
||||||
|
class:border-primary={tab === 'marker'}
|
||||||
|
class:border-transparent={tab !== 'marker'}
|
||||||
|
onclick={() => (tab = 'marker')}
|
||||||
|
>
|
||||||
|
<MapPin class="size-4 inline mr-1" />
|
||||||
|
Marker ({trailMarkers.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
||||||
|
class:border-primary={tab === 'kommentare'}
|
||||||
|
class:border-transparent={tab !== 'kommentare'}
|
||||||
|
onclick={() => (tab = 'kommentare')}
|
||||||
|
>
|
||||||
|
<MessageSquare class="size-4 inline mr-1" />
|
||||||
|
Kommentare ({comments.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
||||||
|
class:border-primary={tab === 'versionen'}
|
||||||
|
class:border-transparent={tab !== 'versionen'}
|
||||||
|
onclick={() => (tab = 'versionen')}
|
||||||
|
>
|
||||||
|
<History class="size-4 inline mr-1" />
|
||||||
|
Versionen ({trailVersions.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === 'marker'}
|
||||||
|
{#if trailMarkers.length === 0}
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Noch keine Marker. Setze einen über „Marker setzen" auf der Karte.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each trailMarkers as m (m.id)}
|
||||||
|
{@const flag = flags.getById(m.flag)}
|
||||||
|
<Card class={m.resolved ? 'opacity-60' : ''}>
|
||||||
|
<CardContent class="py-3 flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="size-3 rounded-full shrink-0"
|
||||||
|
style:background={flag?.color ?? '#64748b'}
|
||||||
|
></span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
|
||||||
|
{#if m.note}
|
||||||
|
<p class="text-sm text-muted-foreground">{m.note}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-muted-foreground">{formatDate(m.created)}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||||
|
onclick={() => markers.toggleResolved(m.id)}
|
||||||
|
>
|
||||||
|
<Check class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if tab === 'kommentare'}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
bind:value={commentText}
|
||||||
|
placeholder="Kommentar schreiben …"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && addComment()}
|
||||||
|
/>
|
||||||
|
<Button onclick={addComment} disabled={!commentText.trim()}>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if commentError}
|
||||||
|
<p class="text-sm text-destructive">{commentError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if comments.length === 0}
|
||||||
|
<p class="text-muted-foreground text-sm">Noch keine Kommentare.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each comments as c (c.id)}
|
||||||
|
<Card>
|
||||||
|
<CardContent class="py-3">
|
||||||
|
<p class="text-sm">{c.text}</p>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if trailVersions.length === 0}
|
||||||
|
<p class="text-muted-foreground text-sm">Noch keine Version hochgeladen.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each trailVersions as v (v.id)}
|
||||||
|
<Card class={v.id === trail.current ? 'border-primary' : ''}>
|
||||||
|
<CardContent class="py-3 flex items-center gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-sm font-medium">{formatDate(v.created)}</p>
|
||||||
|
{#if v.id === trail.current}
|
||||||
|
<Badge>Aktiv</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
|
||||||
|
{#if v.note}· {v.note}{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if v.gpx}
|
||||||
|
<Button variant="ghost" size="sm" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
|
||||||
|
<Download class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canEdit && v.id !== trail.current}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => versions.activate(trail.id, v.id)}
|
||||||
|
>
|
||||||
|
Aktivieren
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- GPX-Upload -->
|
||||||
|
<Dialog.Root bind:open={uploadDialog}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>GPX-Datei hochladen</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Die bisherige Version bleibt erhalten; die neue wird zur aktiven.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="gpx-file">Datei</Label>
|
||||||
|
<input
|
||||||
|
id="gpx-file"
|
||||||
|
type="file"
|
||||||
|
accept=".gpx,application/gpx+xml,application/xml,text/xml"
|
||||||
|
class="block w-full text-sm"
|
||||||
|
onchange={(e) => (uploadFile = e.currentTarget.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="gpx-note">Notiz</Label>
|
||||||
|
<Input id="gpx-note" bind:value={uploadNote} placeholder="z. B. Umleitung am Steinbruch" />
|
||||||
|
</div>
|
||||||
|
{#if uploadError}
|
||||||
|
<p class="text-sm text-destructive">{uploadError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (uploadDialog = false)}>Abbrechen</Button>
|
||||||
|
<Button onclick={doUpload} disabled={!uploadFile || uploading}>
|
||||||
|
{uploading ? 'Wird verarbeitet …' : 'Hochladen'}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Marker anlegen -->
|
||||||
|
<Dialog.Root bind:open={markerDialog}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Marker setzen</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Typ</Label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each flags.scoped as f (f.id)}
|
||||||
|
<Button
|
||||||
|
variant={markerFlag === f.id ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (markerFlag = f.id)}
|
||||||
|
>
|
||||||
|
<span class="size-2 rounded-full mr-2" style:background={f.color}></span>
|
||||||
|
{f.label}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="marker-note">Notiz</Label>
|
||||||
|
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />
|
||||||
|
</div>
|
||||||
|
{#if markerError}
|
||||||
|
<p class="text-sm text-destructive">{markerError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (markerDialog = false)}>Abbrechen</Button>
|
||||||
|
<Button onclick={saveMarker}>Speichern</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
Loading…
Reference in a new issue