diff --git a/backend/pb_migrations/1754400000_init_schema.js b/backend/pb_migrations/1754400000_init_schema.js
index a8ad793..bcfcf21 100644
--- a/backend/pb_migrations/1754400000_init_schema.js
+++ b/backend/pb_migrations/1754400000_init_schema.js
@@ -1512,10 +1512,40 @@ migrate((app) => {
"minSelect": 0,
"name": "flag",
"presentable": false,
- "required": true,
+ "required": false,
"system": false,
"type": "relation"
},
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text_marker_label",
+ "max": 120,
+ "min": 0,
+ "name": "label",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text_marker_icon",
+ "max": 60,
+ "min": 0,
+ "name": "icon",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
{
"help": "",
"hidden": false,
diff --git a/backend/pb_migrations/1754500800_trail_markers_custom.js b/backend/pb_migrations/1754500800_trail_markers_custom.js
new file mode 100644
index 0000000..071f7f4
--- /dev/null
+++ b/backend/pb_migrations/1754500800_trail_markers_custom.js
@@ -0,0 +1,57 @@
+///
+
+// Marker dürfen einen eigenen Text und ein eigenes Icon tragen.
+//
+// Bisher musste jede Meldung auf einen vorgefertigten Flag-Typ zeigen. Auf
+// dem Trail passt aber nicht immer einer: „Wespennest am Anlieger" legt
+// niemand vorher als Typ an. Deshalb:
+//
+// - flag ist nicht mehr Pflicht. Fehlt es, trägt der Marker seinen eigenen
+// Text in label.
+// - icon steht am Marker selbst. Beim Wählen eines Typs kommt dessen Icon als
+// Vorschlag; ändern lässt es sich trotzdem, denn dieselbe Art Hindernis
+// sieht nicht immer gleich aus.
+//
+// Bestehende Marker bleiben unverändert gültig: Sie haben ein flag und weder
+// label noch icon, die Anzeige fällt dann auf den Typ zurück.
+
+migrate((app) => {
+ const markers = app.findCollectionByNameOrId('trail_markers')
+
+ const flag = markers.fields.getByName('flag')
+ if (flag) {
+ flag.required = false
+ }
+
+ if (!markers.fields.getByName('label')) {
+ markers.fields.add(new Field({
+ id: 'text_marker_label',
+ name: 'label',
+ type: 'text',
+ max: 120,
+ }))
+ }
+
+ if (!markers.fields.getByName('icon')) {
+ markers.fields.add(new Field({
+ id: 'text_marker_icon',
+ name: 'icon',
+ type: 'text',
+ max: 60,
+ }))
+ }
+
+ app.save(markers)
+}, (app) => {
+ const markers = app.findCollectionByNameOrId('trail_markers')
+
+ const flag = markers.fields.getByName('flag')
+ if (flag) {
+ flag.required = true
+ }
+
+ markers.fields.removeByName('label')
+ markers.fields.removeByName('icon')
+
+ app.save(markers)
+})
diff --git a/frontend/src/lib/components/IconPicker.svelte b/frontend/src/lib/components/IconPicker.svelte
new file mode 100644
index 0000000..3b82026
--- /dev/null
+++ b/frontend/src/lib/components/IconPicker.svelte
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Jeder Name von lucide.dev
+ ist möglich — geschrieben wie „TreePine" oder „tree-pine".
+ {#if value.trim() && !iconExists(value)}
+ Diesen Namen gibt es dort nicht.
+ {/if}
+
+
+
+ {#each FLAG_ICON_SUGGESTIONS as name (name)}
+
+ {/each}
+
+
diff --git a/frontend/src/lib/stores/trailMarkers.svelte.ts b/frontend/src/lib/stores/trailMarkers.svelte.ts
index 7a1fbe4..7b9a112 100644
--- a/frontend/src/lib/stores/trailMarkers.svelte.ts
+++ b/frontend/src/lib/stores/trailMarkers.svelte.ts
@@ -94,7 +94,19 @@ export class TrailMarkerStore {
}).catch((e) => console.warn('trail_markers subscribe failed:', e))
}
- async create(data: { trail: string; flag: string; lat: number; lng: number; note?: string }) {
+ /**
+ * `flag` ist optional: Ein Marker darf mit eigenem `label` und `icon`
+ * auskommen, wenn kein vorgefertigter Typ passt.
+ */
+ async create(data: {
+ trail: string
+ flag?: string
+ label?: string
+ icon?: string
+ lat: number
+ lng: number
+ note?: string
+ }) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
diff --git a/frontend/src/lib/types.d.ts b/frontend/src/lib/types.d.ts
index df80871..847c28e 100644
--- a/frontend/src/lib/types.d.ts
+++ b/frontend/src/lib/types.d.ts
@@ -144,8 +144,10 @@ export type TrailFlagsRecord = {
export type TrailMarkersRecord = {
created?: IsoDateString
created_by?: RecordIdString
- flag: RecordIdString
+ flag?: RecordIdString
+ icon?: string
id: string
+ label?: string
lat: number
lng: number
note?: string
diff --git a/frontend/src/routes/dashboard/settings/flags/+page.svelte b/frontend/src/routes/dashboard/settings/flags/+page.svelte
index 3b607de..d283833 100644
--- a/frontend/src/routes/dashboard/settings/flags/+page.svelte
+++ b/frontend/src/routes/dashboard/settings/flags/+page.svelte
@@ -8,7 +8,7 @@
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import FlagIcon from '$lib/components/FlagIcon.svelte'
- import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
+ import IconPicker from '$lib/components/IconPicker.svelte'
import { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte'
import type { TrailFlagsResponse } from '$lib/types'
@@ -202,41 +202,8 @@
{/each}
-
-
-
-
-
-
-
-
-
- Jeder Name von lucide.dev
- ist möglich — geschrieben wie „TreePine" oder „tree-pine".
- {#if form.icon.trim() && !iconExists(form.icon)}
- Diesen Namen gibt es dort nicht.
- {/if}
-
+
-
Vorschläge:
-
- {#each FLAG_ICON_SUGGESTIONS as name (name)}
-
- {/each}
-
-
{#if error}
{error}
{/if}
diff --git a/frontend/src/routes/dashboard/trails/[id]/+page.svelte b/frontend/src/routes/dashboard/trails/[id]/+page.svelte
index ccf0274..4846abb 100644
--- a/frontend/src/routes/dashboard/trails/[id]/+page.svelte
+++ b/frontend/src/routes/dashboard/trails/[id]/+page.svelte
@@ -10,6 +10,9 @@
import TrailMap from '$lib/components/TrailMap.svelte'
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
import FlagIcon from '$lib/components/FlagIcon.svelte'
+ import IconPicker from '$lib/components/IconPicker.svelte'
+ import Avatar from '$lib/components/Avatar.svelte'
+ import { tooltip } from '$lib/stores/app.svelte'
import { positionAtDistance } from '$lib/gpx'
import { app } from '$lib/stores/app.svelte'
import { Button } from '@/components/ui/button'
@@ -19,6 +22,7 @@
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import * as DropdownMenu from '@/components/ui/dropdown-menu'
+ import * as Select from '@/components/ui/select'
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
import {
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
@@ -26,7 +30,7 @@
} from 'lucide-svelte'
import DOMPurify from 'dompurify'
import { browser } from '$app/environment'
- import type { TrailCommentsResponse, TrailVersionsResponse } from '$lib/types'
+ import type { TrailCommentsResponse, TrailVersionsResponse, UsersResponse } from '$lib/types'
// Generics der JSON-Felder, wie in trails/+page.svelte — die
// Rohdaten kommen ungetypt aus PocketBase zurück.
@@ -68,20 +72,37 @@
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
const trailMarkers = $derived(trail ? markers.byTrail(trail.id) : [])
+ const NEUTRAL = '#64748b'
+
+ /**
+ * Beschriftung, Icon und Farbe eines Markers. Ein eigener Text oder ein
+ * eigenes Icon am Marker geht vor: Beim Setzen darf der Typ nur den
+ * Vorschlag liefern, nicht das letzte Wort haben.
+ */
+ function markerLook(m: { flag?: string; label?: string; icon?: string }) {
+ const flag = m.flag ? flags.getById(m.flag) : undefined
+
+ return {
+ label: m.label?.trim() || flag?.label || 'Marker',
+ icon: m.icon?.trim() || flag?.icon,
+ color: flag?.color ?? NEUTRAL,
+ }
+ }
+
// Für die Karte aufbereitete Marker — inklusive der Angaben fürs Popup
// (was, wann, wer). Formatiert wird hier, damit die Karte keine Stores
// und keine Datumslogik braucht.
const mapMarkers = $derived(
trailMarkers.map((m) => {
- const flag = flags.getById(m.flag)
+ const look = markerLook(m)
return {
id: m.id,
lat: m.lat,
lng: m.lng,
- color: flag?.color ?? '#64748b',
+ color: look.color,
resolved: !!m.resolved,
- label: flag?.label ?? 'Marker',
- icon: flag?.icon,
+ label: look.label,
+ icon: look.icon,
note: m.note,
when: formatDate(m.created),
who: markers.authorName(m),
@@ -134,23 +155,44 @@
let markerDialog = $state(false)
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
let markerDistance = $state(null)
- let markerFlag = $state('')
+ // Leerer Wert = eigener Typ. Als erste Zeile im Dropdown, damit eine
+ // Meldung nie daran scheitert, dass es den passenden Typ noch nicht gibt.
+ const CUSTOM = ''
+
+ let markerFlag = $state(CUSTOM)
+ let markerLabel = $state('')
+ let markerIcon = $state('')
let markerNote = $state('')
let markerError = $state(null)
- const canPlace = $derived(flags.scoped.length > 0)
+ // Ohne Flag-Typen geht es trotzdem: Dann ist jede Meldung eine eigene.
+ const canPlace = $derived(!!trail)
+
+ const selectedFlag = $derived(markerFlag ? flags.getById(markerFlag) : undefined)
+ const markerColor = $derived(selectedFlag?.color ?? NEUTRAL)
function onPlace(p: { lat: number; lng: number; distance: number | null }) {
if (!canPlace) return
markerCoords = { lat: p.lat, lng: p.lng }
markerDistance = p.distance
- markerFlag = flags.scoped[0]?.id ?? ''
+ markerFlag = flags.scoped[0]?.id ?? CUSTOM
+ markerLabel = ''
+ markerIcon = flags.scoped[0]?.icon ?? ''
markerNote = ''
markerError = null
markerDialog = true
}
+ /**
+ * Typwechsel übernimmt dessen Icon — als Vorschlag. Wer danach ein anderes
+ * wählt, behält es; erst der nächste Typwechsel schlägt wieder etwas vor.
+ */
+ function selectFlag(id: string) {
+ markerFlag = id
+ markerIcon = id ? (flags.getById(id)?.icon ?? '') : ''
+ }
+
function removeMarker(m: { id: string; note?: string }) {
app.confirm.request({
title: 'Marker löschen?',
@@ -187,8 +229,9 @@
async function saveMarker() {
if (!trail || !markerCoords) return
- if (!markerFlag) {
- markerError = 'Bitte einen Flag-Typ wählen.'
+
+ if (!markerFlag && !markerLabel.trim()) {
+ markerError = 'Bitte einen Typ wählen oder eine eigene Bezeichnung eintragen.'
return
}
@@ -196,6 +239,10 @@
await markers.create({
trail: trail.id,
flag: markerFlag,
+ // Bei einem vorgefertigten Typ steht die Bezeichnung schon dort;
+ // sie hier zu kopieren hieße, sie zweimal zu pflegen.
+ label: markerFlag ? '' : markerLabel.trim(),
+ icon: markerIcon.trim(),
lat: markerCoords.lat,
lng: markerCoords.lng,
note: markerNote,
@@ -210,7 +257,9 @@
// --- Kommentare ------------------------------------------------------
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
- let comments = $state([])
+ type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }>
+
+ let comments = $state([])
let commentText = $state('')
let commentError = $state(null)
let unsubComments: (() => void) | null = null
@@ -222,20 +271,30 @@
let cancelled = false
api.collection('trail_comments')
- .getFullList({ filter: `trail="${id}"`, sort: '-created', requestKey: null })
+ .getFullList({
+ filter: `trail="${id}"`,
+ sort: '-created',
+ expand: 'created_by',
+ requestKey: null,
+ })
.then((r) => {
- if (!cancelled) comments = r
+ if (!cancelled) comments = r as Comment[]
})
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
api.collection('trail_comments')
- .subscribe('*', (e) => {
- if (e.record.trail !== id) return
- const idx = comments.findIndex((c) => c.id === e.record.id)
- if (e.action === 'create' && idx === -1) comments = [e.record, ...comments]
- else if (e.action === 'update' && idx !== -1) comments[idx] = e.record
- else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== e.record.id)
- })
+ .subscribe(
+ '*',
+ (e) => {
+ if (e.record.trail !== id) return
+ const record = e.record as Comment
+ const idx = comments.findIndex((c) => c.id === record.id)
+ if (e.action === 'create' && idx === -1) comments = [record, ...comments]
+ else if (e.action === 'update' && idx !== -1) comments[idx] = record
+ else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== record.id)
+ },
+ { expand: 'created_by' },
+ )
.then((u) => {
if (cancelled) u()
else unsubComments = u
@@ -422,28 +481,21 @@
- {#if canPlace}
-
- Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
- Marker zu setzen.
-