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. -

- {:else} -

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

- {/if} +

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

{#if trailMarkers.length === 0}

Noch keine Meldung.

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

    {flag?.label ?? 'Unbekannt'}

    +

    {look.label}

    {#if m.note}

    {m.note}

    {/if} @@ -513,9 +565,20 @@ {:else}
      {#each comments as c (c.id)} -
    • -

      {c.text}

      -

      {formatDate(c.created)}

      + {@const author = c.expand?.created_by} +
    • + + + + +
      +

      {c.text}

      +

      {formatDate(c.created)}

      +
    • {/each}
    @@ -646,19 +709,37 @@
    -
    - {#each flags.scoped as f (f.id)} - - {/each} -
    + + + {#if selectedFlag} + + + {selectedFlag.label} + + {:else} + Eigener Typ + {/if} + + + Eigener Typ … + {#each flags.scoped as f (f.id)} + + + {f.label} + + {/each} + +
    + + {#if !markerFlag} +
    + + +
    + {/if} + +