From f8247ae3e7cee5050501946412324b88d4243612 Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Wed, 2 Sep 2026 12:56:17 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o --- .../lib/components/ElevationProfile.svelte | 72 ++- frontend/src/lib/components/FlagIcon.svelte | 34 + frontend/src/lib/components/TrailMap.svelte | 108 +++- frontend/src/lib/flagIcons.ts | 49 ++ frontend/src/lib/stores/trails.svelte.ts | 12 + frontend/src/lib/trailStatus.ts | 38 ++ .../src/routes/dashboard/trails/+page.svelte | 25 +- .../routes/dashboard/trails/[id]/+page.svelte | 591 ++++++++++-------- 8 files changed, 634 insertions(+), 295 deletions(-) create mode 100644 frontend/src/lib/components/FlagIcon.svelte create mode 100644 frontend/src/lib/flagIcons.ts create mode 100644 frontend/src/lib/trailStatus.ts diff --git a/frontend/src/lib/components/ElevationProfile.svelte b/frontend/src/lib/components/ElevationProfile.svelte index 693903d..b2dba20 100644 --- a/frontend/src/lib/components/ElevationProfile.svelte +++ b/frontend/src/lib/components/ElevationProfile.svelte @@ -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) + } + } {#if stats}
+ + {#if hover} + + {#if loading} + {#await loading} + + {:then mod} + {@const Icon = mod.default as typeof Flag} + + {:catch} + + {/await} + {:else} + + {/if} + diff --git a/frontend/src/lib/components/TrailMap.svelte b/frontend/src/lib/components/TrailMap.svelte index b6237d9..c74da95 100644 --- a/frontend/src/lib/components/TrailMap.svelte +++ b/frontend/src/lib/components/TrailMap.svelte @@ -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 = '© 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 = '© OpenStreetMap-Mitwirkende, © 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(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[] = [] + + 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 - instance.addControl(new maplibregl.NavigationControl(), 'top-right') - instance.addControl(new maplibregl.ScaleControl(), 'bottom-left') + 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; } diff --git a/frontend/src/lib/flagIcons.ts b/frontend/src/lib/flagIcons.ts new file mode 100644 index 0000000..20eeb84 --- /dev/null +++ b/frontend/src/lib/flagIcons.ts @@ -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 +> + +/** `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) | 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', +] diff --git a/frontend/src/lib/stores/trails.svelte.ts b/frontend/src/lib/stores/trails.svelte.ts index 5b4d081..6769880 100644 --- a/frontend/src/lib/stores/trails.svelte.ts +++ b/frontend/src/lib/stores/trails.svelte.ts @@ -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 }) } diff --git a/frontend/src/lib/trailStatus.ts b/frontend/src/lib/trailStatus.ts new file mode 100644 index 0000000..8fe0375 --- /dev/null +++ b/frontend/src/lib/trailStatus.ts @@ -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 +} diff --git a/frontend/src/routes/dashboard/trails/+page.svelte b/frontend/src/routes/dashboard/trails/+page.svelte index 82df400..665cbe6 100644 --- a/frontend/src/routes/dashboard/trails/+page.svelte +++ b/frontend/src/routes/dashboard/trails/+page.svelte @@ -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(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)}
{trail.name} - - {STATUS_LABEL[trail.status] ?? trail.status} - + + + {status.label} +
@@ -135,6 +129,7 @@ geojson={version.geojson} bounds={version.bounds} status={trail.status} + controls={false} height="140px" /> {:else} diff --git a/frontend/src/routes/dashboard/trails/[id]/+page.svelte b/frontend/src/routes/dashboard/trails/[id]/+page.svelte index bfb7fe2..ccf0274 100644 --- a/frontend/src/routes/dashboard/trails/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/trails/[id]/+page.svelte @@ -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(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.') + } } @@ -254,248 +293,302 @@ {:else}
-
-
- -

{trail.name}

- {#if safeDescription} -

{@html safeDescription}

- {/if} -
+
+ + +
+

{trail.name}

-
- - {STATUS_LABEL[trail.status] ?? trail.status} - {#if canEdit} - - {/if} -
-
- - {#if canEdit} -
- {#each ['offen', 'eingeschraenkt', 'gesperrt'] as s (s)} - + {/snippet} + + + Zustand melden + {#each TRAIL_STATUS as s (s.value)} + setStatus(s.value)} + class={s.value === trail.status ? 'bg-accent' : ''} + > + + {s.label} + {s.hint} + + {/each} + + + {:else} + - {STATUS_LABEL[s as keyof typeof STATUS_LABEL]} - - {/each} -
- {/if} - - - - - {#if version?.geojson} - (highlight = d)} - /> - -
- {(version.distance_m / 1000).toFixed(1)} km - {version.ascent_m} hm - {#if canPlace} - - - Auf die Trail-Linie klicken, um dort einen Marker zu setzen - - {/if} -
- - {#if !canPlace} -

- Es gibt noch keine Flag-Typen. - Jetzt anlegen -

- {/if} - - {#if version.elevation?.length} - - (highlight = d)} - /> - {/if} - {:else} -
- -

Für diesen Trail gibt es noch keine GPX-Datei.

- {#if canEdit} - - {/if} -
+ + {statusInfo.label} + {/if} - - - -
- - - + {#if version} + + + + {(version.distance_m / 1000).toFixed(1)} km + + + + {version.ascent_m} hm + + + {/if} +
+ + {#if trail.status_changed} +

+ {statusInfo.label} + gemeldet am {formatDate(trail.status_changed)} + {#if trail.status_by} + von {teams.userLabel(trail.status_by)} + {/if} +

+ {/if} + + {#if safeDescription} +

{@html safeDescription}

+ {/if}
- {#if tab === 'marker'} - {#if trailMarkers.length === 0} -

- Noch keine Marker. Klicke dafür auf der Karte auf die Trail-Linie. -

- {:else} -
- {#each trailMarkers as m (m.id)} - {@const flag = flags.getById(m.flag)} - - - -
-

{flag?.label ?? 'Unbekannt'}

- {#if m.note} -

{m.note}

- {/if} -

{formatDate(m.created)}

-
- {#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])} - - {/if} -
-
- {/each} -
- {/if} - {:else if tab === 'kommentare'} +
+ + + + {#if version?.geojson} + (highlight = d)} + /> + + {#if version.elevation?.length} + (highlight = d)} + onselect={canPlace ? placeFromProfile : undefined} + /> + {/if} + {:else} +
+ +

Für diesen Trail gibt es noch keine GPX-Datei.

+ {#if canEdit} + + {/if} +
+ {/if} +
+
+ +
-
- e.key === 'Enter' && addComment()} - /> - -
- {#if commentError} -

{commentError}

- {/if} + + + + + Marker ({trailMarkers.length}) + + + + {#if canPlace} +

+ Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen + Marker zu setzen. +

+ {:else} +

+ Es gibt noch keine Flag-Typen. + Jetzt anlegen +

+ {/if} - {#if comments.length === 0} -

Noch keine Kommentare.

- {:else} -
- {#each comments as c (c.id)} - - -

{c.text}

-

{formatDate(c.created)}

-
-
- {/each} -
- {/if} -
- {:else} - {#if trailVersions.length === 0} -

Noch keine Version hochgeladen.

- {:else} -
- {#each trailVersions as v (v.id)} - - -
-
-

{formatDate(v.created)}

- {#if v.id === trail.current} - Aktiv + {#if trailMarkers.length === 0} +

Noch keine Meldung.

+ {:else} +
    + {#each trailMarkers as m (m.id)} + {@const flag = flags.getById(m.flag)} +
  • + +
    +

    {flag?.label ?? 'Unbekannt'}

    + {#if m.note} +

    {m.note}

    + {/if} +

    + {formatDate(m.created)} · {markers.authorName(m)} +

    +
    + {#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])} +
    + + +
    {/if} -
-

- {(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm - {#if v.note}· {v.note}{/if} -

-
+ + {/each} + + {/if} +
+
- {#if v.gpx} - - {/if} + + + + + Kommentare ({comments.length}) + + + +
+ e.key === 'Enter' && addComment()} + /> + +
+ {#if commentError} +

{commentError}

+ {/if} - {#if canEdit && v.id !== trail.current} - - {/if} -
-
- {/each} -
- {/if} - {/if} + {#if comments.length === 0} +

Noch keine Kommentare.

+ {:else} +
    + {#each comments as c (c.id)} +
  • +

    {c.text}

    +

    {formatDate(c.created)}

    +
  • + {/each} +
+ {/if} + + + + + + + + {#if showVersions} + + {#if canEdit} + + {/if} + + {#if trailVersions.length === 0} +

Noch keine Version hochgeladen.

+ {:else} +
    + {#each trailVersions as v (v.id)} +
  • +
    +
    +

    {formatDate(v.created)}

    + {#if v.id === trail.current} + Aktiv + {/if} +
    +

    + {(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm + {#if v.note}· {v.note}{/if} +

    +
    + + {#if v.gpx} + + {/if} + + {#if canEdit && v.id !== trail.current} + + {/if} +
  • + {/each} +
+ {/if} +
+ {/if} +
+
+
{/if}