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:
parent
7c3cf29354
commit
f8247ae3e7
8 changed files with 634 additions and 295 deletions
|
|
@ -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}
|
||||||
<!--
|
<!--
|
||||||
|
|
|
||||||
34
frontend/src/lib/components/FlagIcon.svelte
Normal file
34
frontend/src/lib/components/FlagIcon.svelte
Normal 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>
|
||||||
|
|
@ -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 = '© 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 = '© OpenStreetMap-Mitwirkende, © 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
|
||||||
|
|
||||||
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
|
if (controls) {
|
||||||
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
|
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
49
frontend/src/lib/flagIcons.ts
Normal file
49
frontend/src/lib/flagIcons.ts
Normal 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',
|
||||||
|
]
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
frontend/src/lib/trailStatus.ts
Normal file
38
frontend/src/lib/trailStatus.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,248 +293,302 @@
|
||||||
{: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">
|
||||||
{#if safeDescription}
|
<h1 class="text-4xl font-bold tracking-tight">{trail.name}</h1>
|
||||||
<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}
|
{#if canEdit}
|
||||||
<Button variant="outline" size="sm" onclick={() => (uploadDialog = true)}>
|
<DropdownMenu.Root>
|
||||||
<Upload class="size-4 mr-2" />
|
<DropdownMenu.Trigger>
|
||||||
GPX hochladen
|
{#snippet child({ props })}
|
||||||
</Button>
|
<!-- Farbe kommt aus derselben Quelle wie die Linie auf der Karte. -->
|
||||||
{/if}
|
<Button
|
||||||
</div>
|
{...props}
|
||||||
</div>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
{#if canEdit}
|
class="gap-2"
|
||||||
<div class="flex gap-2">
|
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
|
||||||
{#each ['offen', 'eingeschraenkt', 'gesperrt'] as s (s)}
|
>
|
||||||
<Button
|
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
|
||||||
variant={trail.status === s ? 'default' : 'outline'}
|
{statusInfo.label}
|
||||||
size="sm"
|
<ChevronDown class="size-3 opacity-60" />
|
||||||
onclick={() => setStatus(s)}
|
</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}"
|
||||||
>
|
>
|
||||||
{STATUS_LABEL[s as keyof typeof STATUS_LABEL]}
|
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
|
||||||
</Button>
|
{statusInfo.label}
|
||||||
{/each}
|
</span>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Karte -->
|
|
||||||
<Card>
|
|
||||||
<CardContent class="pt-6 space-y-4">
|
|
||||||
{#if version?.geojson}
|
|
||||||
<TrailMap
|
|
||||||
geojson={version.geojson}
|
|
||||||
bounds={version.bounds}
|
|
||||||
markers={mapMarkers}
|
|
||||||
status={trail.status}
|
|
||||||
coordDistances={version.coord_distances ?? []}
|
|
||||||
highlightDistance={highlight}
|
|
||||||
onplace={canPlace ? onPlace : undefined}
|
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="py-12 text-center space-y-3">
|
|
||||||
<Upload class="size-10 mx-auto text-muted-foreground" />
|
|
||||||
<p class="text-muted-foreground">Für diesen Trail gibt es noch keine GPX-Datei.</p>
|
|
||||||
{#if canEdit}
|
|
||||||
<Button onclick={() => (uploadDialog = true)}>
|
|
||||||
<Upload class="size-4 mr-2" />
|
|
||||||
GPX hochladen
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Reiter -->
|
{#if version}
|
||||||
<div class="flex gap-2 border-b">
|
<span class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<button
|
<span class="flex items-center gap-1.5">
|
||||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
<Ruler class="size-4" />
|
||||||
class:border-primary={tab === 'marker'}
|
<strong class="text-foreground">{(version.distance_m / 1000).toFixed(1)}</strong> km
|
||||||
class:border-transparent={tab !== 'marker'}
|
</span>
|
||||||
onclick={() => (tab = 'marker')}
|
<span class="flex items-center gap-1.5">
|
||||||
>
|
<TrendingUp class="size-4" />
|
||||||
<MapPin class="size-4 inline mr-1" />
|
<strong class="text-foreground">{version.ascent_m}</strong> hm
|
||||||
Marker ({trailMarkers.length})
|
</span>
|
||||||
</button>
|
</span>
|
||||||
<button
|
{/if}
|
||||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
</div>
|
||||||
class:border-primary={tab === 'kommentare'}
|
|
||||||
class:border-transparent={tab !== 'kommentare'}
|
{#if trail.status_changed}
|
||||||
onclick={() => (tab = 'kommentare')}
|
<p class="text-xs text-muted-foreground">
|
||||||
>
|
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
|
||||||
<MessageSquare class="size-4 inline mr-1" />
|
gemeldet am {formatDate(trail.status_changed)}
|
||||||
Kommentare ({comments.length})
|
{#if trail.status_by}
|
||||||
</button>
|
von {teams.userLabel(trail.status_by)}
|
||||||
<button
|
{/if}
|
||||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
</p>
|
||||||
class:border-primary={tab === 'versionen'}
|
{/if}
|
||||||
class:border-transparent={tab !== 'versionen'}
|
|
||||||
onclick={() => (tab = 'versionen')}
|
{#if safeDescription}
|
||||||
>
|
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
|
||||||
<History class="size-4 inline mr-1" />
|
{/if}
|
||||||
Versionen ({trailVersions.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if tab === 'marker'}
|
<div class="grid gap-6 lg:grid-cols-3 items-start">
|
||||||
{#if trailMarkers.length === 0}
|
<!-- Karte und Höhenprofil -->
|
||||||
<p class="text-muted-foreground text-sm">
|
<Card class="lg:col-span-2">
|
||||||
Noch keine Marker. Klicke dafür auf der Karte auf die Trail-Linie.
|
<CardContent class="pt-6 space-y-4">
|
||||||
</p>
|
{#if version?.geojson}
|
||||||
{:else}
|
<TrailMap
|
||||||
<div class="space-y-2">
|
geojson={version.geojson}
|
||||||
{#each trailMarkers as m (m.id)}
|
bounds={version.bounds}
|
||||||
{@const flag = flags.getById(m.flag)}
|
markers={mapMarkers}
|
||||||
<Card class={m.resolved ? 'opacity-60' : ''}>
|
status={trail.status}
|
||||||
<CardContent class="py-3 flex items-center gap-3">
|
coordDistances={version.coord_distances ?? []}
|
||||||
<span
|
highlightDistance={highlight}
|
||||||
class="size-3 rounded-full shrink-0"
|
onplace={canPlace ? onPlace : undefined}
|
||||||
style:background={flag?.color ?? '#64748b'}
|
ontrailhover={(d) => (highlight = d)}
|
||||||
></span>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
|
{#if version.elevation?.length}
|
||||||
{#if m.note}
|
<ElevationProfile
|
||||||
<p class="text-sm text-muted-foreground">{m.note}</p>
|
elevation={version.elevation}
|
||||||
{/if}
|
highlightDistance={highlight}
|
||||||
<p class="text-xs text-muted-foreground">{formatDate(m.created)}</p>
|
onhover={(d) => (highlight = d)}
|
||||||
</div>
|
onselect={canPlace ? placeFromProfile : undefined}
|
||||||
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
|
/>
|
||||||
<Button
|
{/if}
|
||||||
variant="ghost"
|
{:else}
|
||||||
size="sm"
|
<div class="py-12 text-center space-y-3">
|
||||||
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
<Upload class="size-10 mx-auto text-muted-foreground" />
|
||||||
onclick={async () => {
|
<p class="text-muted-foreground">Für diesen Trail gibt es noch keine GPX-Datei.</p>
|
||||||
try {
|
{#if canEdit}
|
||||||
await markers.toggleResolved(m.id)
|
<Button onclick={() => (uploadDialog = true)}>
|
||||||
} catch (e: any) {
|
<Upload class="size-4 mr-2" />
|
||||||
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
|
GPX hochladen
|
||||||
}
|
</Button>
|
||||||
}}
|
{/if}
|
||||||
>
|
</div>
|
||||||
<Check class="size-4" />
|
{/if}
|
||||||
</Button>
|
</CardContent>
|
||||||
{/if}
|
</Card>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<!-- Marker, Kommentare, Versionen -->
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if tab === 'kommentare'}
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex gap-2">
|
<Card>
|
||||||
<Input
|
<CardHeader class="pb-3">
|
||||||
bind:value={commentText}
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
placeholder="Kommentar schreiben …"
|
<MapPin class="size-4 text-primary" />
|
||||||
onkeydown={(e) => e.key === 'Enter' && addComment()}
|
Marker ({trailMarkers.length})
|
||||||
/>
|
</CardTitle>
|
||||||
<Button onclick={addComment} disabled={!commentText.trim()}>
|
</CardHeader>
|
||||||
<Plus class="size-4" />
|
<CardContent class="space-y-3">
|
||||||
</Button>
|
{#if canPlace}
|
||||||
</div>
|
<p class="text-xs text-muted-foreground">
|
||||||
{#if commentError}
|
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
|
||||||
<p class="text-sm text-destructive">{commentError}</p>
|
Marker zu setzen.
|
||||||
{/if}
|
</p>
|
||||||
|
{:else}
|
||||||
|
<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 comments.length === 0}
|
{#if trailMarkers.length === 0}
|
||||||
<p class="text-muted-foreground text-sm">Noch keine Kommentare.</p>
|
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<ul class="divide-y -mx-2">
|
||||||
{#each comments as c (c.id)}
|
{#each trailMarkers as m (m.id)}
|
||||||
<Card>
|
{@const flag = flags.getById(m.flag)}
|
||||||
<CardContent class="py-3">
|
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
|
||||||
<p class="text-sm">{c.text}</p>
|
<FlagIcon name={flag?.icon} color={flag?.color ?? '#64748b'} class="size-5" />
|
||||||
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
<div class="min-w-0 flex-1">
|
||||||
</CardContent>
|
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
|
||||||
</Card>
|
{#if m.note}
|
||||||
{/each}
|
<p class="text-sm text-muted-foreground">{m.note}</p>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
<p class="text-xs text-muted-foreground">
|
||||||
</div>
|
{formatDate(m.created)} · {markers.authorName(m)}
|
||||||
{:else}
|
</p>
|
||||||
{#if trailVersions.length === 0}
|
</div>
|
||||||
<p class="text-muted-foreground text-sm">Noch keine Version hochgeladen.</p>
|
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
|
||||||
{:else}
|
<div class="flex shrink-0">
|
||||||
<div class="space-y-2">
|
<Button
|
||||||
{#each trailVersions as v (v.id)}
|
variant="ghost"
|
||||||
<Card class={v.id === trail.current ? 'border-primary' : ''}>
|
size="icon"
|
||||||
<CardContent class="py-3 flex items-center gap-3">
|
class="size-8"
|
||||||
<div class="min-w-0 flex-1">
|
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||||
<div class="flex items-center gap-2">
|
onclick={async () => {
|
||||||
<p class="text-sm font-medium">{formatDate(v.created)}</p>
|
try {
|
||||||
{#if v.id === trail.current}
|
await markers.toggleResolved(m.id)
|
||||||
<Badge>Aktiv</Badge>
|
} catch (e: any) {
|
||||||
|
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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}
|
{/if}
|
||||||
</div>
|
</li>
|
||||||
<p class="text-sm text-muted-foreground">
|
{/each}
|
||||||
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
|
</ul>
|
||||||
{#if v.note}· {v.note}{/if}
|
{/if}
|
||||||
</p>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{#if v.gpx}
|
<Card>
|
||||||
<Button variant="ghost" size="sm" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
|
<CardHeader class="pb-3">
|
||||||
<Download class="size-4" />
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
</Button>
|
<MessageSquare class="size-4 text-primary" />
|
||||||
{/if}
|
Kommentare ({comments.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
bind:value={commentText}
|
||||||
|
placeholder="Kommentar schreiben …"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && addComment()}
|
||||||
|
/>
|
||||||
|
<Button onclick={addComment} disabled={!commentText.trim()}>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if commentError}
|
||||||
|
<p class="text-sm text-destructive">{commentError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if canEdit && v.id !== trail.current}
|
{#if comments.length === 0}
|
||||||
<Button
|
<p class="text-sm text-muted-foreground">Noch keine Kommentare.</p>
|
||||||
variant="outline"
|
{:else}
|
||||||
size="sm"
|
<ul class="divide-y -mx-2">
|
||||||
onclick={() => versions.activate(trail.id, v.id)}
|
{#each comments as c (c.id)}
|
||||||
>
|
<li class="px-2 py-3">
|
||||||
Aktivieren
|
<p class="text-sm">{c.text}</p>
|
||||||
</Button>
|
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
||||||
{/if}
|
</li>
|
||||||
</CardContent>
|
{/each}
|
||||||
</Card>
|
</ul>
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
</CardContent>
|
||||||
{/if}
|
</Card>
|
||||||
{/if}
|
|
||||||
|
<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 trailVersions.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">Noch keine Version hochgeladen.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y -mx-2">
|
||||||
|
{#each trailVersions as v (v.id)}
|
||||||
|
<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>
|
||||||
|
{#if v.id === trail.current}
|
||||||
|
<Badge>Aktiv</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<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="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)}>
|
||||||
|
Aktivieren
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue