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}