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 { let {
elevation, elevation,
onhover, onhover,
onselect,
highlightDistance = null, highlightDistance = null,
height = '120px', height = '120px',
}: { }: {
elevation: { d: number; ele: number }[] elevation: { d: number; ele: number }[]
onhover?: (distance: number | null) => void 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 highlightDistance?: number | null
height?: string height?: string
} = $props() } = $props()
@ -82,32 +89,82 @@
} }
}) })
function handleMove(e: MouseEvent) { /** Distanz an der Mausposition; null, wenn es keine Punkte gibt. */
if (!elevation.length) return 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 ratio = (e.clientX - rect.left) / rect.width
const idx = Math.round(ratio * (elevation.length - 1)) const idx = Math.round(ratio * (elevation.length - 1))
const clamped = Math.max(0, Math.min(elevation.length - 1, idx)) 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() { function handleLeave() {
onhover?.(null) 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> </script>
{#if stats} {#if stats}
<div class="relative w-full" style:height> <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 <svg
viewBox="0 0 {W} {H}" viewBox="0 0 {W} {H}"
preserveAspectRatio="none" preserveAspectRatio="none"
class="w-full h-full" class="w-full h-full"
role="img" role="img"
aria-label="Höhenprofil" aria-hidden="true"
onmousemove={handleMove}
onmouseleave={handleLeave}
> >
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" /> <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} {#if hover}
@ -122,6 +179,7 @@
/> />
{/if} {/if}
</svg> </svg>
</button>
{#if hover} {#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 * Props, Ereignisse gehen über Callback-Props zurück. Damit ist die
* Komponente in Liste, Detailseite und Setzmodus gleichermaßen nutzbar. * 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 * as maplibregl from 'maplibre-gl'
import type { Map as MapLibreMap, Marker, Popup } from 'maplibre-gl' import type { Map as MapLibreMap, Marker, Popup } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css' import 'maplibre-gl/dist/maplibre-gl.css'
@ -23,6 +23,10 @@
// relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404). // relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404).
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx' 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) maplibregl.setWorkerUrl(workerUrl)
@ -35,6 +39,8 @@
resolved: boolean resolved: boolean
/** Was — der Flag-Typ */ /** Was — der Flag-Typ */
label: string label: string
/** Icon-Name des Flag-Typs */
icon?: string
/** Wann — bereits formatiert */ /** Wann — bereits formatiert */
when?: string when?: string
/** Wer — bereits aufgelöst */ /** Wer — bereits aufgelöst */
@ -51,6 +57,7 @@
highlightDistance = null, highlightDistance = null,
onplace, onplace,
ontrailhover, ontrailhover,
controls = true,
height = '420px', height = '420px',
}: { }: {
geojson?: LineGeoJSON | null geojson?: LineGeoJSON | null
@ -67,23 +74,45 @@
onplace?: (p: { lat: number; lng: number; distance: number | null }) => void onplace?: (p: { lat: number; lng: number; distance: number | null }) => void
/** Streckenkilometer unter dem Zeiger, null beim Verlassen der Linie. */ /** Streckenkilometer unter dem Zeiger, null beim Verlassen der Linie. */
ontrailhover?: (distance: number | null) => void 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 height?: string
} = $props() } = $props()
// Kachelquelle als Konstante, damit sie sich später leicht gegen eine /**
// Outdoor-Karte mit Höhenlinien tauschen lässt. * Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen
const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' * keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt
const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende' * 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. // Fallback, wenn ein Trail noch keine Version hat: Hersbruck.
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089] const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
const LINE_COLORS = {
offen: '#16a34a',
eingeschraenkt: '#ca8a04',
gesperrt: '#dc2626',
} as const
let container: HTMLDivElement let container: HTMLDivElement
let map = $state<MapLibreMap | null>(null) let map = $state<MapLibreMap | null>(null)
let ready = $state(false) let ready = $state(false)
@ -91,6 +120,23 @@
let highlightMarker: Marker | null = null let highlightMarker: Marker | null = null
let popup: Popup | 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(() => { onMount(() => {
let cleanupMap: (() => void) | null = null let cleanupMap: (() => void) | null = null
@ -102,7 +148,7 @@
sources: { sources: {
osm: { osm: {
type: 'raster', type: 'raster',
tiles: [TILES], tiles: isDark ? TILES_DARK : TILES_LIGHT,
tileSize: 256, tileSize: 256,
attribution: ATTRIBUTION, attribution: ATTRIBUTION,
}, },
@ -111,11 +157,14 @@
}, },
center: FALLBACK_CENTER, center: FALLBACK_CENTER,
zoom: 11, zoom: 11,
attributionControl: controls ? undefined : false,
}) })
map = instance map = instance
if (controls) {
instance.addControl(new maplibregl.NavigationControl(), 'top-right') instance.addControl(new maplibregl.NavigationControl(), 'top-right')
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left') instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
}
instance.on('load', () => { instance.on('load', () => {
ready = true ready = true
@ -130,6 +179,7 @@
highlightMarker = null highlightMarker = null
popup?.remove() popup?.remove()
popup = null popup = null
clearIcons()
instance.remove() instance.remove()
map = null map = null
@ -207,7 +257,7 @@
if (!map || !ready) return if (!map || !ready) return
const data = geojson const data = geojson
const color = LINE_COLORS[status] ?? LINE_COLORS.offen const color = trailStatusColor(status)
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
if (!data) { 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 // Kartenausschnitt auf den Track setzen
$effect(() => { $effect(() => {
if (!map || !ready || !bounds) return if (!map || !ready || !bounds) return
@ -256,10 +314,11 @@
const head = document.createElement('div') const head = document.createElement('div')
head.className = 'trail-popup-head' head.className = 'trail-popup-head'
const dot = document.createElement('span') const icon = document.createElement('span')
dot.className = 'trail-popup-dot' icon.className = 'trail-popup-icon'
dot.style.background = data.color icon.style.color = data.color
head.appendChild(dot) mountIcon(icon, data.icon, 'size-4')
head.appendChild(icon)
const title = document.createElement('strong') const title = document.createElement('strong')
// textContent, nicht innerHTML: Label und Notiz sind Nutzereingaben. // textContent, nicht innerHTML: Label und Notiz sind Nutzereingaben.
@ -298,6 +357,7 @@
for (const m of markerObjects) m.remove() for (const m of markerObjects) m.remove()
markerObjects = [] markerObjects = []
clearIcons()
for (const data of markers) { for (const data of markers) {
const el = document.createElement('button') const el = document.createElement('button')
@ -305,12 +365,14 @@
el.title = data.label el.title = data.label
el.setAttribute('aria-label', data.label) el.setAttribute('aria-label', data.label)
el.style.cssText = ` 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; border: 2px solid white; cursor: pointer; padding: 0;
display: grid; place-items: center; color: white;
background: ${data.color}; background: ${data.color};
opacity: ${data.resolved ? '0.35' : '1'}; opacity: ${data.resolved ? '0.35' : '1'};
box-shadow: 0 1px 4px rgba(0,0,0,.4); 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 // Der Klick bleibt beim Marker: Er darf kein neues Marker-Fenster
// öffnen, sondern zeigt nur, was hier schon gemeldet wurde. // öffnen, sondern zeigt nur, was hier schon gemeldet wurde.
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
@ -396,10 +458,8 @@
color: #0f172a; color: #0f172a;
} }
:global(.trail-popup-dot) { :global(.trail-popup-icon) {
width: 0.5rem; display: inline-flex;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0; 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) 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[]) { async setStewards(id: string, userIds: string[]) {
return await api.collection('trails').update(id, { stewards: userIds }) 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 { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import TrailMap from '$lib/components/TrailMap.svelte' import TrailMap from '$lib/components/TrailMap.svelte'
import { trailStatus } from '$lib/trailStatus'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -22,18 +23,6 @@
let saving = $state(false) let saving = $state(false)
let error = $state<string | null>(null) 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][] } type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
/** Aktive Version eines Trails, für Kennzahlen und Kartenlinie */ /** Aktive Version eines Trails, für Kennzahlen und Kartenlinie */
@ -119,14 +108,19 @@
{#each trails.scoped as trail (trail.id)} {#each trails.scoped as trail (trail.id)}
{@const version = currentVersion(trail.id)} {@const version = currentVersion(trail.id)}
{@const open = markers.openByTrail(trail.id).length} {@const open = markers.openByTrail(trail.id).length}
{@const status = trailStatus(trail.status)}
<a href="/dashboard/trails/{trail.id}" class="block"> <a href="/dashboard/trails/{trail.id}" class="block">
<Card class="h-full hover:border-primary transition-colors"> <Card class="h-full hover:border-primary transition-colors">
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<CardTitle class="text-base">{trail.name}</CardTitle> <CardTitle class="text-base">{trail.name}</CardTitle>
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}> <span
{STATUS_LABEL[trail.status] ?? trail.status} class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
</Badge> style="color: {status.color}; border-color: {status.color}"
>
<span class="size-1.5 rounded-full" style:background={status.color}></span>
{status.label}
</span>
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="space-y-3"> <CardContent class="space-y-3">
@ -135,6 +129,7 @@
geojson={version.geojson} geojson={version.geojson}
bounds={version.bounds} bounds={version.bounds}
status={trail.status} status={trail.status}
controls={false}
height="140px" height="140px"
/> />
{:else} {:else}

View file

@ -9,17 +9,20 @@
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
import TrailMap from '$lib/components/TrailMap.svelte' import TrailMap from '$lib/components/TrailMap.svelte'
import ElevationProfile from '$lib/components/ElevationProfile.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 { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog' import * as Dialog from '@/components/ui/dialog'
import * as Select from '@/components/ui/select' import * as DropdownMenu from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator' import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
import { import {
ArrowLeft, Upload, MapPin, MessageSquare, History, ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
Check, Trash2, Download, Plus, AlertTriangle, Check, Trash2, Download, Plus, Ruler, TrendingUp,
} from 'lucide-svelte' } from 'lucide-svelte'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { browser } from '$app/environment' import { browser } from '$app/environment'
@ -78,6 +81,7 @@
color: flag?.color ?? '#64748b', color: flag?.color ?? '#64748b',
resolved: !!m.resolved, resolved: !!m.resolved,
label: flag?.label ?? 'Marker', label: flag?.label ?? 'Marker',
icon: flag?.icon,
note: m.note, note: m.note,
when: formatDate(m.created), when: formatDate(m.created),
who: markers.authorName(m), who: markers.authorName(m),
@ -85,8 +89,16 @@
}), }),
) )
let tab = $state<'marker' | 'kommentare' | 'versionen'>('marker')
let highlight = $state<number | null>(null) 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 ------------------------------------------------------ // --- GPX-Upload ------------------------------------------------------
let uploadDialog = $state(false) let uploadDialog = $state(false)
@ -139,6 +151,40 @@
markerDialog = true 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() { async function saveMarker() {
if (!trail || !markerCoords) return if (!trail || !markerCoords) return
if (!markerFlag) { if (!markerFlag) {
@ -221,25 +267,18 @@
} }
// --- Hilfsfunktionen ------------------------------------------------- // --- 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) { function formatDate(s: string) {
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} }
async function setStatus(status: string) { async function setStatus(status: string) {
if (!trail) return if (!trail || status === trail.status) return
await trails.edit(trail.id, { status } as any)
try {
await trails.setStatus(trail.id, status)
} catch (e: any) {
alert(e.message ?? 'Der Zustand konnte nicht gemeldet werden.')
}
} }
</script> </script>
@ -254,47 +293,89 @@
{:else} {:else}
<div class="space-y-6"> <div class="space-y-6">
<!-- Kopf --> <!-- Kopf -->
<div class="flex items-start justify-between gap-4"> <div class="space-y-2">
<div class="space-y-1">
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}> <Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" /> <ArrowLeft class="size-4 mr-2" />
Trails Trails
</Button> </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} {#if safeDescription}
<p class="text-muted-foreground text-sm">{@html safeDescription}</p> <p class="text-muted-foreground text-sm">{@html safeDescription}</p>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-2"> <div class="grid gap-6 lg:grid-cols-3 items-start">
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}> <!-- Karte und Höhenprofil -->
{STATUS_LABEL[trail.status] ?? trail.status} <Card class="lg:col-span-2">
</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>
<CardContent class="pt-6 space-y-4"> <CardContent class="pt-6 space-y-4">
{#if version?.geojson} {#if version?.geojson}
<TrailMap <TrailMap
@ -308,30 +389,12 @@
ontrailhover={(d) => (highlight = d)} 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} {#if version.elevation?.length}
<Separator />
<ElevationProfile <ElevationProfile
elevation={version.elevation} elevation={version.elevation}
highlightDistance={highlight} highlightDistance={highlight}
onhover={(d) => (highlight = d)} onhover={(d) => (highlight = d)}
onselect={canPlace ? placeFromProfile : undefined}
/> />
{/if} {/if}
{:else} {:else}
@ -349,63 +412,51 @@
</CardContent> </CardContent>
</Card> </Card>
<!-- Reiter --> <!-- Marker, Kommentare, Versionen -->
<div class="flex gap-2 border-b"> <div class="space-y-4">
<button <Card>
class="px-4 py-2 text-sm border-b-2 -mb-px" <CardHeader class="pb-3">
class:border-primary={tab === 'marker'} <CardTitle class="flex items-center gap-2 text-base">
class:border-transparent={tab !== 'marker'} <MapPin class="size-4 text-primary" />
onclick={() => (tab = 'marker')}
>
<MapPin class="size-4 inline mr-1" />
Marker ({trailMarkers.length}) Marker ({trailMarkers.length})
</button> </CardTitle>
<button </CardHeader>
class="px-4 py-2 text-sm border-b-2 -mb-px" <CardContent class="space-y-3">
class:border-primary={tab === 'kommentare'} {#if canPlace}
class:border-transparent={tab !== 'kommentare'} <p class="text-xs text-muted-foreground">
onclick={() => (tab = 'kommentare')} Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
> Marker zu setzen.
<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.
</p> </p>
{:else} {: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)} {#each trailMarkers as m (m.id)}
{@const flag = flags.getById(m.flag)} {@const flag = flags.getById(m.flag)}
<Card class={m.resolved ? 'opacity-60' : ''}> <li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
<CardContent class="py-3 flex items-center gap-3"> <FlagIcon name={flag?.icon} color={flag?.color ?? '#64748b'} class="size-5" />
<span
class="size-3 rounded-full shrink-0"
style:background={flag?.color ?? '#64748b'}
></span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p> <p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
{#if m.note} {#if m.note}
<p class="text-sm text-muted-foreground">{m.note}</p> <p class="text-sm text-muted-foreground">{m.note}</p>
{/if} {/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> </div>
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])} {#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
<div class="flex shrink-0">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
class="size-8"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'} title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => { onclick={async () => {
try { try {
@ -417,14 +468,32 @@
> >
<Check class="size-4" /> <Check class="size-4" />
</Button> </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} {/if}
</CardContent> </CardContent>
</Card> </Card>
{/each}
</div> <Card>
{/if} <CardHeader class="pb-3">
{:else if tab === 'kommentare'} <CardTitle class="flex items-center gap-2 text-base">
<div class="space-y-4"> <MessageSquare class="size-4 text-primary" />
Kommentare ({comments.length})
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex gap-2"> <div class="flex gap-2">
<Input <Input
bind:value={commentText} bind:value={commentText}
@ -440,28 +509,53 @@
{/if} {/if}
{#if comments.length === 0} {#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} {:else}
<div class="space-y-2"> <ul class="divide-y -mx-2">
{#each comments as c (c.id)} {#each comments as c (c.id)}
<Card> <li class="px-2 py-3">
<CardContent class="py-3">
<p class="text-sm">{c.text}</p> <p class="text-sm">{c.text}</p>
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p> <p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
</li>
{/each}
</ul>
{/if}
</CardContent> </CardContent>
</Card> </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} {/if}
</div>
{:else}
{#if trailVersions.length === 0} {#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} {:else}
<div class="space-y-2"> <ul class="divide-y -mx-2">
{#each trailVersions as v (v.id)} {#each trailVersions as v (v.id)}
<Card class={v.id === trail.current ? 'border-primary' : ''}> <li class="px-2 py-3 flex items-start gap-2">
<CardContent class="py-3 flex items-center gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<p class="text-sm font-medium">{formatDate(v.created)}</p> <p class="text-sm font-medium">{formatDate(v.created)}</p>
@ -469,33 +563,32 @@
<Badge>Aktiv</Badge> <Badge>Aktiv</Badge>
{/if} {/if}
</div> </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 {(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
{#if v.note}· {v.note}{/if} {#if v.note}· {v.note}{/if}
</p> </p>
</div> </div>
{#if v.gpx} {#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" /> <Download class="size-4" />
</Button> </Button>
{/if} {/if}
{#if canEdit && v.id !== trail.current} {#if canEdit && v.id !== trail.current}
<Button <Button variant="outline" size="sm" onclick={() => versions.activate(trail.id, v.id)}>
variant="outline"
size="sm"
onclick={() => versions.activate(trail.id, v.id)}
>
Aktivieren Aktivieren
</Button> </Button>
{/if} {/if}
</CardContent> </li>
</Card>
{/each} {/each}
</ul>
{/if}
</CardContent>
{/if}
</Card>
</div>
</div> </div>
{/if}
{/if}
</div> </div>
{/if} {/if}