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 {
|
||||
elevation,
|
||||
onhover,
|
||||
onselect,
|
||||
highlightDistance = null,
|
||||
height = '120px',
|
||||
}: {
|
||||
elevation: { d: number; ele: number }[]
|
||||
onhover?: (distance: number | null) => void
|
||||
/**
|
||||
* Klick auf eine Stelle des Profils. Damit lässt sich ein Marker auch
|
||||
* über die Höhe finden — im steilen Stück, statt die Linie auf der
|
||||
* Karte abzusuchen.
|
||||
*/
|
||||
onselect?: (distance: number) => void
|
||||
highlightDistance?: number | null
|
||||
height?: string
|
||||
} = $props()
|
||||
|
|
@ -82,32 +89,82 @@
|
|||
}
|
||||
})
|
||||
|
||||
function handleMove(e: MouseEvent) {
|
||||
if (!elevation.length) return
|
||||
/** Distanz an der Mausposition; null, wenn es keine Punkte gibt. */
|
||||
function distanceAt(e: MouseEvent): number | null {
|
||||
if (!elevation.length) return null
|
||||
|
||||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect()
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const ratio = (e.clientX - rect.left) / rect.width
|
||||
const idx = Math.round(ratio * (elevation.length - 1))
|
||||
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
|
||||
|
||||
onhover?.(elevation[clamped].d)
|
||||
return elevation[clamped].d
|
||||
}
|
||||
|
||||
function handleMove(e: MouseEvent) {
|
||||
const d = distanceAt(e)
|
||||
if (d !== null) onhover?.(d)
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!onselect) return
|
||||
|
||||
const d = distanceAt(e)
|
||||
if (d !== null) onselect(d)
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
onhover?.(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tastaturweg zum Setzen: Mit den Pfeiltasten die Stelle wählen, mit
|
||||
* Enter oder Leertaste bestätigen. Ohne das wäre das Profil ein reines
|
||||
* Mausziel.
|
||||
*/
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (!onselect || !stats) return
|
||||
|
||||
const total = stats.totalD
|
||||
const step = total / 50
|
||||
const current = highlightDistance ?? 0
|
||||
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
const next = e.key === 'ArrowRight' ? current + step : current - step
|
||||
onhover?.(Math.max(0, Math.min(total, next)))
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (highlightDistance != null) onselect(highlightDistance)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stats}
|
||||
<div class="relative w-full" style:height>
|
||||
<!--
|
||||
Die Fläche ist ein echter Knopf: Sie reagiert auf Zeiger und
|
||||
Tastatur, und ein <svg> mit Rollen und tabindex bleibt für
|
||||
Hilfsmittel ein Bild mit angeklebtem Verhalten.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full h-full border-0 bg-transparent p-0"
|
||||
class:cursor-pointer={!!onselect}
|
||||
aria-label={onselect
|
||||
? 'Höhenprofil – hier klicken setzt einen Marker an dieser Stelle'
|
||||
: 'Höhenprofil'}
|
||||
onmousemove={handleMove}
|
||||
onmouseleave={handleLeave}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKey}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 {W} {H}"
|
||||
preserveAspectRatio="none"
|
||||
class="w-full h-full"
|
||||
role="img"
|
||||
aria-label="Höhenprofil"
|
||||
onmousemove={handleMove}
|
||||
onmouseleave={handleLeave}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
|
||||
{#if hover}
|
||||
|
|
@ -122,6 +179,7 @@
|
|||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if hover}
|
||||
<!--
|
||||
|
|
|
|||
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
|
||||
* 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<MapLibreMap | null>(null)
|
||||
let ready = $state(false)
|
||||
|
|
@ -91,6 +120,23 @@
|
|||
let highlightMarker: Marker | null = null
|
||||
let popup: Popup | null = null
|
||||
|
||||
/**
|
||||
* MapLibre will für einen Marker ein DOM-Element, Icons kommen aber als
|
||||
* Svelte-Komponenten. `mount` schlägt die Brücke — dafür müssen die
|
||||
* Instanzen von Hand wieder abgeräumt werden, sonst bleiben sie beim
|
||||
* Neuaufbau der Marker hängen.
|
||||
*/
|
||||
let iconInstances: Record<string, unknown>[] = []
|
||||
|
||||
function mountIcon(target: HTMLElement, name: string | undefined, size: string) {
|
||||
iconInstances.push(mount(FlagIcon, { target, props: { name, class: size } }))
|
||||
}
|
||||
|
||||
function clearIcons() {
|
||||
for (const inst of iconInstances) unmount(inst)
|
||||
iconInstances = []
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cleanupMap: (() => void) | null = null
|
||||
|
||||
|
|
@ -102,7 +148,7 @@
|
|||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: [TILES],
|
||||
tiles: isDark ? TILES_DARK : TILES_LIGHT,
|
||||
tileSize: 256,
|
||||
attribution: ATTRIBUTION,
|
||||
},
|
||||
|
|
@ -111,11 +157,14 @@
|
|||
},
|
||||
center: FALLBACK_CENTER,
|
||||
zoom: 11,
|
||||
attributionControl: controls ? undefined : false,
|
||||
})
|
||||
map = instance
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
|
|
|
|||
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 { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||
import { trailStatus } from '$lib/trailStatus'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -22,18 +23,6 @@
|
|||
let saving = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const STATUS_LABEL = {
|
||||
offen: 'Offen',
|
||||
eingeschraenkt: 'Eingeschränkt',
|
||||
gesperrt: 'Gesperrt',
|
||||
} as const
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
offen: 'default',
|
||||
eingeschraenkt: 'secondary',
|
||||
gesperrt: 'destructive',
|
||||
} as const
|
||||
|
||||
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
|
||||
|
||||
/** Aktive Version eines Trails, für Kennzahlen und Kartenlinie */
|
||||
|
|
@ -119,14 +108,19 @@
|
|||
{#each trails.scoped as trail (trail.id)}
|
||||
{@const version = currentVersion(trail.id)}
|
||||
{@const open = markers.openByTrail(trail.id).length}
|
||||
{@const status = trailStatus(trail.status)}
|
||||
<a href="/dashboard/trails/{trail.id}" class="block">
|
||||
<Card class="h-full hover:border-primary transition-colors">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle class="text-base">{trail.name}</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
|
||||
{STATUS_LABEL[trail.status] ?? trail.status}
|
||||
</Badge>
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
|
||||
style="color: {status.color}; border-color: {status.color}"
|
||||
>
|
||||
<span class="size-1.5 rounded-full" style:background={status.color}></span>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
|
|
@ -135,6 +129,7 @@
|
|||
geojson={version.geojson}
|
||||
bounds={version.bounds}
|
||||
status={trail.status}
|
||||
controls={false}
|
||||
height="140px"
|
||||
/>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,20 @@
|
|||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
|
||||
import FlagIcon from '$lib/components/FlagIcon.svelte'
|
||||
import { positionAtDistance } from '$lib/gpx'
|
||||
import { app } from '$lib/stores/app.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import * as Dialog from '@/components/ui/dialog'
|
||||
import * as Select from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import * as DropdownMenu from '@/components/ui/dropdown-menu'
|
||||
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
|
||||
import {
|
||||
ArrowLeft, Upload, MapPin, MessageSquare, History,
|
||||
Check, Trash2, Download, Plus, AlertTriangle,
|
||||
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
|
||||
Check, Trash2, Download, Plus, Ruler, TrendingUp,
|
||||
} from 'lucide-svelte'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { browser } from '$app/environment'
|
||||
|
|
@ -78,6 +81,7 @@
|
|||
color: flag?.color ?? '#64748b',
|
||||
resolved: !!m.resolved,
|
||||
label: flag?.label ?? 'Marker',
|
||||
icon: flag?.icon,
|
||||
note: m.note,
|
||||
when: formatDate(m.created),
|
||||
who: markers.authorName(m),
|
||||
|
|
@ -85,8 +89,16 @@
|
|||
}),
|
||||
)
|
||||
|
||||
let tab = $state<'marker' | 'kommentare' | 'versionen'>('marker')
|
||||
let highlight = $state<number | null>(null)
|
||||
// Versionen sind Archiv, kein Alltag — deshalb zugeklappt.
|
||||
let showVersions = $state(false)
|
||||
|
||||
const statusInfo = $derived(trailStatus(trail?.status))
|
||||
|
||||
// Wer den Status gemeldet hat, steht nur als ID im Datensatz.
|
||||
$effect(() => {
|
||||
if (trail?.status_by) teams.loadUser(trail.status_by)
|
||||
})
|
||||
|
||||
// --- GPX-Upload ------------------------------------------------------
|
||||
let uploadDialog = $state(false)
|
||||
|
|
@ -139,6 +151,40 @@
|
|||
markerDialog = true
|
||||
}
|
||||
|
||||
function removeMarker(m: { id: string; note?: string }) {
|
||||
app.confirm.request({
|
||||
title: 'Marker löschen?',
|
||||
text: m.note
|
||||
? `„${m.note}" wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.`
|
||||
: 'Der Marker wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.',
|
||||
yes: async () => {
|
||||
try {
|
||||
await markers.remove(m.id)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Der Marker konnte nicht gelöscht werden.')
|
||||
} finally {
|
||||
app.confirm.close()
|
||||
}
|
||||
},
|
||||
no: () => app.confirm.close(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker aus dem Höhenprofil heraus. Die Stelle kommt als Streckenkilometer
|
||||
* und wird auf dieselbe Linie zurückgerechnet, die auch ein Klick auf der
|
||||
* Karte trifft — beide Wege landen deshalb exakt auf dem Trail.
|
||||
*/
|
||||
function placeFromProfile(distance: number) {
|
||||
const coords = version?.geojson?.coordinates
|
||||
if (!coords?.length) return
|
||||
|
||||
const at = positionAtDistance(coords, version?.coord_distances ?? [], distance)
|
||||
if (!at) return
|
||||
|
||||
onPlace({ lng: at[0], lat: at[1], distance })
|
||||
}
|
||||
|
||||
async function saveMarker() {
|
||||
if (!trail || !markerCoords) return
|
||||
if (!markerFlag) {
|
||||
|
|
@ -221,25 +267,18 @@
|
|||
}
|
||||
|
||||
// --- Hilfsfunktionen -------------------------------------------------
|
||||
const STATUS_LABEL = {
|
||||
offen: 'Offen',
|
||||
eingeschraenkt: 'Eingeschränkt',
|
||||
gesperrt: 'Gesperrt',
|
||||
} as const
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
offen: 'default',
|
||||
eingeschraenkt: 'secondary',
|
||||
gesperrt: 'destructive',
|
||||
} as const
|
||||
|
||||
function formatDate(s: string) {
|
||||
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
async function setStatus(status: string) {
|
||||
if (!trail) return
|
||||
await trails.edit(trail.id, { status } as any)
|
||||
if (!trail || status === trail.status) return
|
||||
|
||||
try {
|
||||
await trails.setStatus(trail.id, status)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Der Zustand konnte nicht gemeldet werden.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -254,248 +293,302 @@
|
|||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Kopf -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
|
||||
<ArrowLeft class="size-4 mr-2" />
|
||||
Trails
|
||||
</Button>
|
||||
<h1 class="text-2xl font-semibold">{trail.name}</h1>
|
||||
{#if safeDescription}
|
||||
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
|
||||
<ArrowLeft class="size-4 mr-2" />
|
||||
Trails
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<h1 class="text-4xl font-bold tracking-tight">{trail.name}</h1>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
|
||||
{STATUS_LABEL[trail.status] ?? trail.status}
|
||||
</Badge>
|
||||
{#if canEdit}
|
||||
<Button variant="outline" size="sm" onclick={() => (uploadDialog = true)}>
|
||||
<Upload class="size-4 mr-2" />
|
||||
GPX hochladen
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if canEdit}
|
||||
<div class="flex gap-2">
|
||||
{#each ['offen', 'eingeschraenkt', 'gesperrt'] as s (s)}
|
||||
<Button
|
||||
variant={trail.status === s ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setStatus(s)}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<!-- Farbe kommt aus derselben Quelle wie die Linie auf der Karte. -->
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="gap-2"
|
||||
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
|
||||
>
|
||||
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
|
||||
{statusInfo.label}
|
||||
<ChevronDown class="size-3 opacity-60" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="start" class="min-w-60">
|
||||
<DropdownMenu.Label>Zustand melden</DropdownMenu.Label>
|
||||
{#each TRAIL_STATUS as s (s.value)}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => setStatus(s.value)}
|
||||
class={s.value === trail.status ? 'bg-accent' : ''}
|
||||
>
|
||||
<span class="size-2 rounded-full mr-2 shrink-0" style:background={s.color}></span>
|
||||
<span class="flex-1">{s.label}</span>
|
||||
<span class="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm"
|
||||
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
|
||||
>
|
||||
{STATUS_LABEL[s as keyof typeof STATUS_LABEL]}
|
||||
</Button>
|
||||
{/each}
|
||||
</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>
|
||||
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Reiter -->
|
||||
<div class="flex gap-2 border-b">
|
||||
<button
|
||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
||||
class:border-primary={tab === 'marker'}
|
||||
class:border-transparent={tab !== 'marker'}
|
||||
onclick={() => (tab = 'marker')}
|
||||
>
|
||||
<MapPin class="size-4 inline mr-1" />
|
||||
Marker ({trailMarkers.length})
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
||||
class:border-primary={tab === 'kommentare'}
|
||||
class:border-transparent={tab !== 'kommentare'}
|
||||
onclick={() => (tab = 'kommentare')}
|
||||
>
|
||||
<MessageSquare class="size-4 inline mr-1" />
|
||||
Kommentare ({comments.length})
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
||||
class:border-primary={tab === 'versionen'}
|
||||
class:border-transparent={tab !== 'versionen'}
|
||||
onclick={() => (tab = 'versionen')}
|
||||
>
|
||||
<History class="size-4 inline mr-1" />
|
||||
Versionen ({trailVersions.length})
|
||||
</button>
|
||||
{#if version}
|
||||
<span class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Ruler class="size-4" />
|
||||
<strong class="text-foreground">{(version.distance_m / 1000).toFixed(1)}</strong> km
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<TrendingUp class="size-4" />
|
||||
<strong class="text-foreground">{version.ascent_m}</strong> hm
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if trail.status_changed}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
|
||||
gemeldet am {formatDate(trail.status_changed)}
|
||||
{#if trail.status_by}
|
||||
von {teams.userLabel(trail.status_by)}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if safeDescription}
|
||||
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if tab === 'marker'}
|
||||
{#if trailMarkers.length === 0}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Noch keine Marker. Klicke dafür auf der Karte auf die Trail-Linie.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each trailMarkers as m (m.id)}
|
||||
{@const flag = flags.getById(m.flag)}
|
||||
<Card class={m.resolved ? 'opacity-60' : ''}>
|
||||
<CardContent class="py-3 flex items-center gap-3">
|
||||
<span
|
||||
class="size-3 rounded-full shrink-0"
|
||||
style:background={flag?.color ?? '#64748b'}
|
||||
></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
|
||||
{#if m.note}
|
||||
<p class="text-sm text-muted-foreground">{m.note}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">{formatDate(m.created)}</p>
|
||||
</div>
|
||||
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||
onclick={async () => {
|
||||
try {
|
||||
await markers.toggleResolved(m.id)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if tab === 'kommentare'}
|
||||
<div class="grid gap-6 lg:grid-cols-3 items-start">
|
||||
<!-- Karte und Höhenprofil -->
|
||||
<Card class="lg:col-span-2">
|
||||
<CardContent class="pt-6 space-y-4">
|
||||
{#if version?.geojson}
|
||||
<TrailMap
|
||||
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)}
|
||||
/>
|
||||
|
||||
{#if version.elevation?.length}
|
||||
<ElevationProfile
|
||||
elevation={version.elevation}
|
||||
highlightDistance={highlight}
|
||||
onhover={(d) => (highlight = d)}
|
||||
onselect={canPlace ? placeFromProfile : undefined}
|
||||
/>
|
||||
{/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}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Marker, Kommentare, Versionen -->
|
||||
<div class="space-y-4">
|
||||
<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}
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<MapPin class="size-4 text-primary" />
|
||||
Marker ({trailMarkers.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
{#if canPlace}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
|
||||
Marker zu setzen.
|
||||
</p>
|
||||
{:else}
|
||||
<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}
|
||||
<p class="text-muted-foreground text-sm">Noch keine Kommentare.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each comments as c (c.id)}
|
||||
<Card>
|
||||
<CardContent class="py-3">
|
||||
<p class="text-sm">{c.text}</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if trailVersions.length === 0}
|
||||
<p class="text-muted-foreground text-sm">Noch keine Version hochgeladen.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each trailVersions as v (v.id)}
|
||||
<Card class={v.id === trail.current ? 'border-primary' : ''}>
|
||||
<CardContent class="py-3 flex items-center gap-3">
|
||||
<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 trailMarkers.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
|
||||
{:else}
|
||||
<ul class="divide-y -mx-2">
|
||||
{#each trailMarkers as m (m.id)}
|
||||
{@const flag = flags.getById(m.flag)}
|
||||
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
|
||||
<FlagIcon name={flag?.icon} color={flag?.color ?? '#64748b'} class="size-5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
|
||||
{#if m.note}
|
||||
<p class="text-sm text-muted-foreground">{m.note}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{formatDate(m.created)} · {markers.authorName(m)}
|
||||
</p>
|
||||
</div>
|
||||
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
|
||||
<div class="flex shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||
onclick={async () => {
|
||||
try {
|
||||
await markers.toggleResolved(m.id)
|
||||
} 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}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
|
||||
{#if v.note}· {v.note}{/if}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if v.gpx}
|
||||
<Button variant="ghost" size="sm" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
|
||||
<Download class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<MessageSquare class="size-4 text-primary" />
|
||||
Kommentare ({comments.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={commentText}
|
||||
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}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => versions.activate(trail.id, v.id)}
|
||||
>
|
||||
Aktivieren
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if comments.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Noch keine Kommentare.</p>
|
||||
{:else}
|
||||
<ul class="divide-y -mx-2">
|
||||
{#each comments as c (c.id)}
|
||||
<li class="px-2 py-3">
|
||||
<p class="text-sm">{c.text}</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue