feat: Trail-Seite neu geordnet, echte dunkle Karte, sichtbare Flag-Icons

Der Kopf traegt jetzt Name, Zustand und Kennzahlen: Der Status sitzt als
farbiges Dropdown direkt hinter dem Namen und nennt darunter, wann und von
wem er gemeldet wurde. Laenge und Hoehenmeter stehen mit Icons daneben.

Karte und Hoehenprofil links, Marker, Kommentare und Versionen rechts
daneben in einer Spalte. Die Reiter entfallen - alles ist gleichzeitig zu
sehen. Versionen sind Archiv und deshalb eingeklappt; der GPX-Upload sitzt
dort statt im Kopf.

Marker lassen sich jetzt auch loeschen und auch ueber das Hoehenprofil
setzen: Die angeklickte Stelle wird auf dieselbe Linie zurueckgerechnet,
die ein Klick auf der Karte trifft.

Die Flag-Icons wurden bisher gespeichert, aber nirgends gezeichnet. Sie
erscheinen jetzt in der Karte, im Popup, in der Markerliste und in der
Verwaltung. Erlaubt ist jeder Name von lucide.dev - moeglich macht das ein
Glob ueber die Icon-Dateien, der jedes Icon zu einem eigenen, erst bei
Bedarf geladenen Chunk macht. Die Liste im Dialog sind nur Vorschlaege.

Die Karte kommt von CARTO statt von OSM direkt: zurueckhaltend gezeichnet,
sodass die Trail-Linie darueber steht, und mit einer echten dunklen
Fassung. Das vorherige Abdunkeln der OSM-Kacheln ergab nur ein dunkleres
Bild, keine dunkle Karte. In der Kachelvorschau der Liste sind Zoom,
Massstab und Quellenangabe abgeschaltet - dort ist die Karte Bild, nicht
Werkzeug; die grosse Karte traegt die Angabe weiterhin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
This commit is contained in:
Daniel Michelberger 2026-09-02 12:56:17 +02:00
parent 7c3cf29354
commit f8247ae3e7
8 changed files with 634 additions and 295 deletions

View file

@ -10,11 +10,18 @@
let {
elevation,
onhover,
onselect,
highlightDistance = null,
height = '120px',
}: {
elevation: { d: number; ele: number }[]
onhover?: (distance: number | null) => void
/**
* Klick auf eine Stelle des Profils. Damit lässt sich ein Marker auch
* über die Höhe finden — im steilen Stück, statt die Linie auf der
* Karte abzusuchen.
*/
onselect?: (distance: number) => void
highlightDistance?: number | null
height?: string
} = $props()
@ -82,32 +89,82 @@
}
})
function handleMove(e: MouseEvent) {
if (!elevation.length) return
/** Distanz an der Mausposition; null, wenn es keine Punkte gibt. */
function distanceAt(e: MouseEvent): number | null {
if (!elevation.length) return null
const rect = (e.currentTarget as SVGElement).getBoundingClientRect()
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
const idx = Math.round(ratio * (elevation.length - 1))
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
onhover?.(elevation[clamped].d)
return elevation[clamped].d
}
function handleMove(e: MouseEvent) {
const d = distanceAt(e)
if (d !== null) onhover?.(d)
}
function handleClick(e: MouseEvent) {
if (!onselect) return
const d = distanceAt(e)
if (d !== null) onselect(d)
}
function handleLeave() {
onhover?.(null)
}
/**
* Tastaturweg zum Setzen: Mit den Pfeiltasten die Stelle wählen, mit
* Enter oder Leertaste bestätigen. Ohne das wäre das Profil ein reines
* Mausziel.
*/
function handleKey(e: KeyboardEvent) {
if (!onselect || !stats) return
const total = stats.totalD
const step = total / 50
const current = highlightDistance ?? 0
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault()
const next = e.key === 'ArrowRight' ? current + step : current - step
onhover?.(Math.max(0, Math.min(total, next)))
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (highlightDistance != null) onselect(highlightDistance)
}
}
</script>
{#if stats}
<div class="relative w-full" style:height>
<!--
Die Fläche ist ein echter Knopf: Sie reagiert auf Zeiger und
Tastatur, und ein <svg> mit Rollen und tabindex bleibt für
Hilfsmittel ein Bild mit angeklebtem Verhalten.
-->
<button
type="button"
class="block w-full h-full border-0 bg-transparent p-0"
class:cursor-pointer={!!onselect}
aria-label={onselect
? 'Höhenprofil hier klicken setzt einen Marker an dieser Stelle'
: 'Höhenprofil'}
onmousemove={handleMove}
onmouseleave={handleLeave}
onclick={handleClick}
onkeydown={handleKey}
>
<svg
viewBox="0 0 {W} {H}"
preserveAspectRatio="none"
class="w-full h-full"
role="img"
aria-label="Höhenprofil"
onmousemove={handleMove}
onmouseleave={handleLeave}
aria-hidden="true"
>
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
{#if hover}
@ -122,6 +179,7 @@
/>
{/if}
</svg>
</button>
{#if hover}
<!--

View file

@ -0,0 +1,34 @@
<script lang="ts">
/**
* Rendert das Icon eines Flag-Typs. Der Name kommt aus der Datenbank und
* ist Freitext — solange geladen wird und bei unbekannten Namen steht
* ersatzweise die Flagge da, statt eine Lücke zu lassen.
*/
import { Flag } from 'lucide-svelte'
import { iconLoader } from '$lib/flagIcons'
let {
name,
color,
class: className = 'size-4',
}: { name?: string; color?: string; class?: string } = $props()
// Das Versprechen einmal je Name bilden, nicht bei jedem Rendern neu.
const loading = $derived(iconLoader(name)?.() ?? null)
</script>
<!-- Farbe über den Wrapper: Lucide zeichnet mit currentColor. -->
<span class="inline-flex shrink-0" style:color>
{#if loading}
{#await loading}
<Flag class={className} />
{:then mod}
{@const Icon = mod.default as typeof Flag}
<Icon class={className} />
{:catch}
<Flag class={className} />
{/await}
{:else}
<Flag class={className} />
{/if}
</span>

View file

@ -4,7 +4,7 @@
* Props, Ereignisse gehen über Callback-Props zurück. Damit ist die
* Komponente in Liste, Detailseite und Setzmodus gleichermaßen nutzbar.
*/
import { onMount } from 'svelte'
import { mount, onMount, unmount } from 'svelte'
import * as maplibregl from 'maplibre-gl'
import type { Map as MapLibreMap, Marker, Popup } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
@ -23,6 +23,10 @@
// relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404).
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx'
import FlagIcon from './FlagIcon.svelte'
import { trailStatusColor } from '$lib/trailStatus'
import { env } from '$env/dynamic/public'
import { mode } from 'mode-watcher'
maplibregl.setWorkerUrl(workerUrl)
@ -35,6 +39,8 @@
resolved: boolean
/** Was — der Flag-Typ */
label: string
/** Icon-Name des Flag-Typs */
icon?: string
/** Wann — bereits formatiert */
when?: string
/** Wer — bereits aufgelöst */
@ -51,6 +57,7 @@
highlightDistance = null,
onplace,
ontrailhover,
controls = true,
height = '420px',
}: {
geojson?: LineGeoJSON | null
@ -67,23 +74,45 @@
onplace?: (p: { lat: number; lng: number; distance: number | null }) => void
/** Streckenkilometer unter dem Zeiger, null beim Verlassen der Linie. */
ontrailhover?: (distance: number | null) => void
/**
* Zoom-Knöpfe, Maßstab und Quellenangabe. In der Kachelvorschau
* abgeschaltet — dort ist die Karte Bild, nicht Werkzeug. Die
* Quellenangabe zu OpenStreetMap trägt die große Karte.
*/
controls?: boolean
height?: string
} = $props()
// Kachelquelle als Konstante, damit sie sich später leicht gegen eine
// Outdoor-Karte mit Höhenlinien tauschen lässt.
const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende'
/**
* Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen
* keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt
* dadurch deutlich sichtbarer darüber — und es gibt sie in einer echten
* dunklen Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein
* dunkleres Bild, keine dunkle Karte.
*
* Mehrere Subdomains, weil Browser die Verbindungen pro Host begrenzen.
*/
// Der Schlüssel muss den Browser erreichen — er kommt deshalb aus den
// PUBLIC_-Variablen. Über $env/dynamic/public statt static, weil die
// Kacheln auch ohne Schlüssel ausgeliefert werden: Fehlt die Variable,
// soll der Build nicht abbrechen.
const CARTO_KEY = env.PUBLIC_CARTO_API_KEY ?? ''
const KEY_PARAM = CARTO_KEY ? `?key=${CARTO_KEY}` : ''
const cartoTiles = (style: 'light_all' | 'dark_all') =>
['a', 'b', 'c'].map(
(sub) => `https://${sub}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}.png${KEY_PARAM}`,
)
const TILES_LIGHT = cartoTiles('light_all')
const TILES_DARK = cartoTiles('dark_all')
const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende, &copy; CARTO'
const isDark = $derived(mode.current === 'dark')
// Fallback, wenn ein Trail noch keine Version hat: Hersbruck.
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
const LINE_COLORS = {
offen: '#16a34a',
eingeschraenkt: '#ca8a04',
gesperrt: '#dc2626',
} as const
let container: HTMLDivElement
let map = $state<MapLibreMap | null>(null)
let ready = $state(false)
@ -91,6 +120,23 @@
let highlightMarker: Marker | null = null
let popup: Popup | null = null
/**
* MapLibre will für einen Marker ein DOM-Element, Icons kommen aber als
* Svelte-Komponenten. `mount` schlägt die Brücke — dafür müssen die
* Instanzen von Hand wieder abgeräumt werden, sonst bleiben sie beim
* Neuaufbau der Marker hängen.
*/
let iconInstances: Record<string, unknown>[] = []
function mountIcon(target: HTMLElement, name: string | undefined, size: string) {
iconInstances.push(mount(FlagIcon, { target, props: { name, class: size } }))
}
function clearIcons() {
for (const inst of iconInstances) unmount(inst)
iconInstances = []
}
onMount(() => {
let cleanupMap: (() => void) | null = null
@ -102,7 +148,7 @@
sources: {
osm: {
type: 'raster',
tiles: [TILES],
tiles: isDark ? TILES_DARK : TILES_LIGHT,
tileSize: 256,
attribution: ATTRIBUTION,
},
@ -111,11 +157,14 @@
},
center: FALLBACK_CENTER,
zoom: 11,
attributionControl: controls ? undefined : false,
})
map = instance
if (controls) {
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
}
instance.on('load', () => {
ready = true
@ -130,6 +179,7 @@
highlightMarker = null
popup?.remove()
popup = null
clearIcons()
instance.remove()
map = null
@ -207,7 +257,7 @@
if (!map || !ready) return
const data = geojson
const color = LINE_COLORS[status] ?? LINE_COLORS.offen
const color = trailStatusColor(status)
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
if (!data) {
@ -242,6 +292,14 @@
}
})
/** Beim Themenwechsel die Kachelquelle austauschen. */
$effect(() => {
if (!map || !ready) return
const src = map.getSource('osm') as maplibregl.RasterTileSource | undefined
src?.setTiles(isDark ? TILES_DARK : TILES_LIGHT)
})
// Kartenausschnitt auf den Track setzen
$effect(() => {
if (!map || !ready || !bounds) return
@ -256,10 +314,11 @@
const head = document.createElement('div')
head.className = 'trail-popup-head'
const dot = document.createElement('span')
dot.className = 'trail-popup-dot'
dot.style.background = data.color
head.appendChild(dot)
const icon = document.createElement('span')
icon.className = 'trail-popup-icon'
icon.style.color = data.color
mountIcon(icon, data.icon, 'size-4')
head.appendChild(icon)
const title = document.createElement('strong')
// textContent, nicht innerHTML: Label und Notiz sind Nutzereingaben.
@ -298,6 +357,7 @@
for (const m of markerObjects) m.remove()
markerObjects = []
clearIcons()
for (const data of markers) {
const el = document.createElement('button')
@ -305,12 +365,14 @@
el.title = data.label
el.setAttribute('aria-label', data.label)
el.style.cssText = `
width: 18px; height: 18px; border-radius: 9999px;
width: 26px; height: 26px; border-radius: 9999px;
border: 2px solid white; cursor: pointer; padding: 0;
display: grid; place-items: center; color: white;
background: ${data.color};
opacity: ${data.resolved ? '0.35' : '1'};
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
mountIcon(el, data.icon, 'size-3.5')
// Der Klick bleibt beim Marker: Er darf kein neues Marker-Fenster
// öffnen, sondern zeigt nur, was hier schon gemeldet wurde.
el.addEventListener('click', (ev) => {
@ -396,10 +458,8 @@
color: #0f172a;
}
:global(.trail-popup-dot) {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
:global(.trail-popup-icon) {
display: inline-flex;
flex-shrink: 0;
}

View file

@ -0,0 +1,49 @@
/**
* Icons für Flag-Typen.
*
* Erlaubt ist jeder Name aus lucide.dev die feste Liste unten sind nur
* Vorschläge fürs schnelle Klicken. Möglich macht das ein Glob über die
* Icon-Dateien des Pakets: Jedes Icon wird ein eigener Chunk und erst geladen,
* wenn es wirklich gebraucht wird. Ein statischer Import des ganzen Pakets
* hätte mehrere hundert Kilobyte für eine Handvoll Symbole gekostet, ein
* dynamischer Import mit Variable ließe sich vom Bundler nicht auflösen.
*/
type IconModule = { default: unknown }
const modules = import.meta.glob('/node_modules/lucide-svelte/dist/icons/*.svelte') as Record<
string,
() => Promise<IconModule>
>
/** `TreePine`, `tree-pine` und `treePine` führen zum selben Icon. */
export function toKebab(name: string): string {
return name
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase()
}
/** Lader für einen Icon-Namen; null, wenn es das Icon nicht gibt. */
export function iconLoader(name: string | undefined): (() => Promise<IconModule>) | null {
if (!name?.trim()) return null
return modules[`/node_modules/lucide-svelte/dist/icons/${toKebab(name)}.svelte`] ?? null
}
/** Ob ein eingegebener Name existiert — für die Rückmeldung im Dialog. */
export function iconExists(name: string | undefined): boolean {
return iconLoader(name) !== null
}
/**
* Vorschläge für die Auswahl im Dialog. Bewusst nach Themen sortiert und
* nicht abschließend: Wer etwas anderes braucht, tippt den Namen ein.
*/
export const FLAG_ICON_SUGGESTIONS = [
'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info',
'TriangleAlert', 'Mountain', 'Droplets', 'CloudRain', 'Snowflake', 'Flame',
'Zap', 'Bike', 'Wrench', 'Hammer', 'Shovel', 'Route',
'Milestone', 'Signpost', 'Footprints', 'Fence', 'Lock', 'CircleParking',
'CameraOff', 'Skull', 'Bug', 'Leaf', 'Trash2', 'Flag',
]

View file

@ -92,6 +92,18 @@ export class TrailStore {
return await api.collection('trails').update(id, data)
}
/**
* Status melden. Wann und von wem kommt gleich mit ohne das ließe sich
* einer Sperrung nicht ansehen, ob sie von heute oder vom letzten Jahr ist.
*/
async setStatus(id: string, status: string) {
return await api.collection('trails').update(id, {
status,
status_changed: new Date().toISOString(),
status_by: auth.user?.id ?? '',
})
}
async setStewards(id: string, userIds: string[]) {
return await api.collection('trails').update(id, { stewards: userIds })
}

View file

@ -0,0 +1,38 @@
/**
* Zustände eines Trails samt Farbe und Beschriftung.
*
* An einer Stelle, weil dieselben Farben in der Kartenlinie, im Abzeichen und
* im Auswahlmenü auftauchen liefen sie auseinander, hieße Grün auf der Karte
* etwas anderes als Grün daneben.
*/
export type TrailStatusValue = 'offen' | 'eingeschraenkt' | 'gesperrt'
export type TrailStatusInfo = {
value: TrailStatusValue
label: string
/** Als CSS-Farbe, auch für MapLibre-Paint-Eigenschaften verwendbar. */
color: string
/** Kurzer Hinweis, was der Zustand bedeutet. */
hint: string
}
export const TRAIL_STATUS: TrailStatusInfo[] = [
{ value: 'offen', label: 'Offen', color: '#16a34a', hint: 'befahrbar' },
{
value: 'eingeschraenkt',
label: 'Eingeschränkt',
color: '#ca8a04',
hint: 'befahrbar, aber mit Hindernissen',
},
{ value: 'gesperrt', label: 'Gesperrt', color: '#dc2626', hint: 'nicht befahren' },
]
const BY_VALUE = new Map(TRAIL_STATUS.map((s) => [s.value, s]))
export function trailStatus(value: string | undefined): TrailStatusInfo {
return (value && BY_VALUE.get(value as TrailStatusValue)) || TRAIL_STATUS[0]
}
export function trailStatusColor(value: string | undefined): string {
return trailStatus(value).color
}

View file

@ -4,6 +4,7 @@
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 { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -22,18 +23,6 @@
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 */
@ -119,14 +108,19 @@
{#each trails.scoped as trail (trail.id)}
{@const version = currentVersion(trail.id)}
{@const open = markers.openByTrail(trail.id).length}
{@const status = trailStatus(trail.status)}
<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>
<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">
@ -135,6 +129,7 @@
geojson={version.geojson}
bounds={version.bounds}
status={trail.status}
controls={false}
height="140px"
/>
{:else}

View file

@ -9,17 +9,20 @@
import { getTeamContext } from '$lib/stores/teams.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
import FlagIcon from '$lib/components/FlagIcon.svelte'
import { positionAtDistance } from '$lib/gpx'
import { app } from '$lib/stores/app.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 * as DropdownMenu from '@/components/ui/dropdown-menu'
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
import {
ArrowLeft, Upload, MapPin, MessageSquare, History,
Check, Trash2, Download, Plus, AlertTriangle,
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
Check, Trash2, Download, Plus, Ruler, TrendingUp,
} from 'lucide-svelte'
import DOMPurify from 'dompurify'
import { browser } from '$app/environment'
@ -78,6 +81,7 @@
color: flag?.color ?? '#64748b',
resolved: !!m.resolved,
label: flag?.label ?? 'Marker',
icon: flag?.icon,
note: m.note,
when: formatDate(m.created),
who: markers.authorName(m),
@ -85,8 +89,16 @@
}),
)
let tab = $state<'marker' | 'kommentare' | 'versionen'>('marker')
let highlight = $state<number | null>(null)
// Versionen sind Archiv, kein Alltag — deshalb zugeklappt.
let showVersions = $state(false)
const statusInfo = $derived(trailStatus(trail?.status))
// Wer den Status gemeldet hat, steht nur als ID im Datensatz.
$effect(() => {
if (trail?.status_by) teams.loadUser(trail.status_by)
})
// --- GPX-Upload ------------------------------------------------------
let uploadDialog = $state(false)
@ -139,6 +151,40 @@
markerDialog = true
}
function removeMarker(m: { id: string; note?: string }) {
app.confirm.request({
title: 'Marker löschen?',
text: m.note
? `„${m.note}" wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.`
: 'Der Marker wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.',
yes: async () => {
try {
await markers.remove(m.id)
} catch (e: any) {
alert(e.message ?? 'Der Marker konnte nicht gelöscht werden.')
} finally {
app.confirm.close()
}
},
no: () => app.confirm.close(),
})
}
/**
* Marker aus dem Höhenprofil heraus. Die Stelle kommt als Streckenkilometer
* und wird auf dieselbe Linie zurückgerechnet, die auch ein Klick auf der
* Karte trifft — beide Wege landen deshalb exakt auf dem Trail.
*/
function placeFromProfile(distance: number) {
const coords = version?.geojson?.coordinates
if (!coords?.length) return
const at = positionAtDistance(coords, version?.coord_distances ?? [], distance)
if (!at) return
onPlace({ lng: at[0], lat: at[1], distance })
}
async function saveMarker() {
if (!trail || !markerCoords) return
if (!markerFlag) {
@ -221,25 +267,18 @@
}
// --- 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)
if (!trail || status === trail.status) return
try {
await trails.setStatus(trail.id, status)
} catch (e: any) {
alert(e.message ?? 'Der Zustand konnte nicht gemeldet werden.')
}
}
</script>
@ -254,47 +293,89 @@
{:else}
<div class="space-y-6">
<!-- Kopf -->
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<div class="space-y-2">
<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>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<h1 class="text-4xl font-bold tracking-tight">{trail.name}</h1>
{#if canEdit}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<!-- Farbe kommt aus derselben Quelle wie die Linie auf der Karte. -->
<Button
{...props}
variant="outline"
size="sm"
class="gap-2"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
<ChevronDown class="size-3 opacity-60" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-60">
<DropdownMenu.Label>Zustand melden</DropdownMenu.Label>
{#each TRAIL_STATUS as s (s.value)}
<DropdownMenu.Item
onclick={() => setStatus(s.value)}
class={s.value === trail.status ? 'bg-accent' : ''}
>
<span class="size-2 rounded-full mr-2 shrink-0" style:background={s.color}></span>
<span class="flex-1">{s.label}</span>
<span class="text-xs text-muted-foreground">{s.hint}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<span
class="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
</span>
{/if}
{#if version}
<span class="flex items-center gap-4 text-sm text-muted-foreground">
<span class="flex items-center gap-1.5">
<Ruler class="size-4" />
<strong class="text-foreground">{(version.distance_m / 1000).toFixed(1)}</strong> km
</span>
<span class="flex items-center gap-1.5">
<TrendingUp class="size-4" />
<strong class="text-foreground">{version.ascent_m}</strong> hm
</span>
</span>
{/if}
</div>
{#if trail.status_changed}
<p class="text-xs text-muted-foreground">
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
gemeldet am {formatDate(trail.status_changed)}
{#if trail.status_by}
von {teams.userLabel(trail.status_by)}
{/if}
</p>
{/if}
{#if safeDescription}
<p class="text-muted-foreground text-sm">{@html safeDescription}</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 (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>
<div class="grid gap-6 lg:grid-cols-3 items-start">
<!-- Karte und Höhenprofil -->
<Card class="lg:col-span-2">
<CardContent class="pt-6 space-y-4">
{#if version?.geojson}
<TrailMap
@ -308,30 +389,12 @@
ontrailhover={(d) => (highlight = d)}
/>
<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>
{#if canPlace}
<span class="ml-auto flex items-center gap-2 text-muted-foreground">
<MapPin class="size-4" />
Auf die Trail-Linie klicken, um dort einen Marker zu setzen
</span>
{/if}
</div>
{#if !canPlace}
<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}
highlightDistance={highlight}
onhover={(d) => (highlight = d)}
onselect={canPlace ? placeFromProfile : undefined}
/>
{/if}
{:else}
@ -349,63 +412,51 @@
</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, Kommentare, Versionen -->
<div class="space-y-4">
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MapPin class="size-4 text-primary" />
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. Klicke dafür auf der Karte auf die Trail-Linie.
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
{#if canPlace}
<p class="text-xs text-muted-foreground">
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
Marker zu setzen.
</p>
{:else}
<div class="space-y-2">
<p class="text-xs text-muted-foreground">
Es gibt noch keine Flag-Typen.
<a href="/dashboard/settings/flags" class="underline">Jetzt anlegen</a>
</p>
{/if}
{#if trailMarkers.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
{:else}
<ul class="divide-y -mx-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>
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
<FlagIcon name={flag?.icon} color={flag?.color ?? '#64748b'} class="size-5" />
<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>
<p class="text-xs text-muted-foreground">
{formatDate(m.created)} · {markers.authorName(m)}
</p>
</div>
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
<div class="flex shrink-0">
<Button
variant="ghost"
size="sm"
size="icon"
class="size-8"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => {
try {
@ -417,14 +468,32 @@
>
<Check class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
title="Marker löschen"
onclick={() => removeMarker(m)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
{:else if tab === 'kommentare'}
<div class="space-y-4">
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MessageSquare class="size-4 text-primary" />
Kommentare ({comments.length})
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex gap-2">
<Input
bind:value={commentText}
@ -440,28 +509,53 @@
{/if}
{#if comments.length === 0}
<p class="text-muted-foreground text-sm">Noch keine Kommentare.</p>
<p class="text-sm text-muted-foreground">Noch keine Kommentare.</p>
{:else}
<div class="space-y-2">
<ul class="divide-y -mx-2">
{#each comments as c (c.id)}
<Card>
<CardContent class="py-3">
<li class="px-2 py-3">
<p class="text-sm">{c.text}</p>
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
{/each}
</div>
<Card>
<!-- Versionen sind Archiv: zugeklappt, bis jemand sie braucht. -->
<button
type="button"
class="w-full flex items-center gap-2 px-6 py-4 text-left"
aria-expanded={showVersions}
onclick={() => (showVersions = !showVersions)}
>
<History class="size-4 text-primary" />
<span class="font-semibold text-base flex-1">
Versionen ({trailVersions.length})
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform"
style={showVersions ? 'transform: rotate(180deg)' : ''}
/>
</button>
{#if showVersions}
<CardContent class="pt-0 space-y-3">
{#if canEdit}
<Button variant="outline" size="sm" class="w-full" onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
</div>
{:else}
{#if trailVersions.length === 0}
<p class="text-muted-foreground text-sm">Noch keine Version hochgeladen.</p>
<p class="text-sm text-muted-foreground">Noch keine Version hochgeladen.</p>
{:else}
<div class="space-y-2">
<ul class="divide-y -mx-2">
{#each trailVersions as v (v.id)}
<Card class={v.id === trail.current ? 'border-primary' : ''}>
<CardContent class="py-3 flex items-center gap-3">
<li class="px-2 py-3 flex items-start gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{formatDate(v.created)}</p>
@ -469,33 +563,32 @@
<Badge>Aktiv</Badge>
{/if}
</div>
<p class="text-sm text-muted-foreground">
<p class="text-xs 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">
<Button variant="ghost" size="icon" class="size-8" 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)}
>
<Button variant="outline" size="sm" onclick={() => versions.activate(trail.id, v.id)}>
Aktivieren
</Button>
{/if}
</CardContent>
</Card>
</li>
{/each}
</ul>
{/if}
</CardContent>
{/if}
</Card>
</div>
</div>
{/if}
{/if}
</div>
{/if}