feat: Marker mit eigenem Typ und frei waehlbarem Icon
Auf dem Trail passt nicht immer ein vorgefertigter Typ - "Wespennest am Anlieger" legt niemand vorher an. Der Marker-Dialog fuehrt deshalb ein Dropdown, dessen erste Zeile "Eigener Typ" ist und ein Textfeld oeffnet. Das Icon steht ab jetzt am Marker selbst. Ein gewaehlter Typ schlaegt seines vor, ueberschreiben laesst es sich jederzeit: Dieselbe Art Hindernis sieht nicht immer gleich aus. Der Picker ist deshalb immer sichtbar und wandert als eigene Komponente auch in die Flag-Verwaltung. Schema: trail_markers.flag ist nicht mehr Pflicht, dafuer gibt es label und icon. Bestehende Marker bleiben gueltig - ohne eigene Angaben faellt die Anzeige auf den Typ zurueck. Damit braucht ein Trail auch keine Flag-Typen mehr, um ueberhaupt Meldungen aufnehmen zu koennen. 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
6b1c57ba17
commit
fe8e301e65
7 changed files with 300 additions and 87 deletions
|
|
@ -1512,10 +1512,40 @@ migrate((app) => {
|
||||||
"minSelect": 0,
|
"minSelect": 0,
|
||||||
"name": "flag",
|
"name": "flag",
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"required": true,
|
"required": false,
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "relation"
|
"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": "",
|
"help": "",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
|
|
|
||||||
57
backend/pb_migrations/1754500800_trail_markers_custom.js
Normal file
57
backend/pb_migrations/1754500800_trail_markers_custom.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
64
frontend/src/lib/components/IconPicker.svelte
Normal file
64
frontend/src/lib/components/IconPicker.svelte
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Icon-Auswahl: Vorschau, Freitextfeld und ein Raster mit Vorschlägen.
|
||||||
|
* Erlaubt ist jeder Name von lucide.dev — die Vorschläge sind nur die
|
||||||
|
* Abkürzung fürs häufig Gebrauchte.
|
||||||
|
*
|
||||||
|
* Gemeinsam genutzt von der Flag-Verwaltung und dem Marker-Dialog, damit
|
||||||
|
* beide dieselbe Auswahl zeigen.
|
||||||
|
*/
|
||||||
|
import FlagIcon from './FlagIcon.svelte'
|
||||||
|
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
color = undefined,
|
||||||
|
id = 'icon-picker',
|
||||||
|
label = 'Icon',
|
||||||
|
}: {
|
||||||
|
value?: string
|
||||||
|
/** Färbt Vorschau und Vorschläge, damit man das Ergebnis sieht. */
|
||||||
|
color?: string
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
} = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for={id}>{label}</Label>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="grid size-9 shrink-0 place-items-center rounded-md border">
|
||||||
|
<FlagIcon name={value} {color} class="size-5" />
|
||||||
|
</span>
|
||||||
|
<Input {id} bind:value placeholder="z. B. TreePine" class="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Jeder Name von <a href="https://lucide.dev/icons" target="_blank" rel="noreferrer" class="underline">lucide.dev</a>
|
||||||
|
ist möglich — geschrieben wie „TreePine" oder „tree-pine".
|
||||||
|
{#if value.trim() && !iconExists(value)}
|
||||||
|
<span class="text-destructive">Diesen Namen gibt es dort nicht.</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-10 gap-1 rounded-lg border p-2">
|
||||||
|
{#each FLAG_ICON_SUGGESTIONS as name (name)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={name}
|
||||||
|
aria-label={name}
|
||||||
|
aria-pressed={value === name}
|
||||||
|
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
|
||||||
|
class:bg-accent={value === name}
|
||||||
|
class:ring-2={value === name}
|
||||||
|
class:ring-primary={value === name}
|
||||||
|
onclick={() => (value = name)}
|
||||||
|
>
|
||||||
|
<FlagIcon {name} {color} class="size-4" />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -94,7 +94,19 @@ export class TrailMarkerStore {
|
||||||
}).catch((e) => console.warn('trail_markers subscribe failed:', e))
|
}).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
|
const teamId = this.teams.activeId
|
||||||
if (!teamId) throw new Error('Kein aktives Team')
|
if (!teamId) throw new Error('Kein aktives Team')
|
||||||
|
|
||||||
|
|
|
||||||
4
frontend/src/lib/types.d.ts
vendored
4
frontend/src/lib/types.d.ts
vendored
|
|
@ -144,8 +144,10 @@ export type TrailFlagsRecord = {
|
||||||
export type TrailMarkersRecord = {
|
export type TrailMarkersRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
created_by?: RecordIdString
|
created_by?: RecordIdString
|
||||||
flag: RecordIdString
|
flag?: RecordIdString
|
||||||
|
icon?: string
|
||||||
id: string
|
id: string
|
||||||
|
label?: string
|
||||||
lat: number
|
lat: number
|
||||||
lng: number
|
lng: number
|
||||||
note?: string
|
note?: string
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
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 FlagIcon from '$lib/components/FlagIcon.svelte'
|
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 { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte'
|
||||||
import type { TrailFlagsResponse } from '$lib/types'
|
import type { TrailFlagsResponse } from '$lib/types'
|
||||||
|
|
||||||
|
|
@ -202,41 +202,8 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<IconPicker id="flag-icon" bind:value={form.icon} color={form.color} />
|
||||||
<Label for="flag-icon">Icon</Label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="grid size-9 place-items-center rounded-md border">
|
|
||||||
<FlagIcon name={form.icon} color={form.color} class="size-5" />
|
|
||||||
</span>
|
|
||||||
<Input id="flag-icon" bind:value={form.icon} placeholder="z. B. TreePine" class="flex-1" />
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Jeder Name von <a href="https://lucide.dev/icons" target="_blank" rel="noreferrer" class="underline">lucide.dev</a>
|
|
||||||
ist möglich — geschrieben wie „TreePine" oder „tree-pine".
|
|
||||||
{#if form.icon.trim() && !iconExists(form.icon)}
|
|
||||||
<span class="text-destructive">Diesen Namen gibt es dort nicht.</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="text-xs text-muted-foreground pt-1">Vorschläge:</p>
|
|
||||||
<div class="grid grid-cols-10 gap-1 rounded-lg border p-2">
|
|
||||||
{#each FLAG_ICON_SUGGESTIONS as name (name)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={name}
|
|
||||||
aria-label={name}
|
|
||||||
aria-pressed={form.icon === name}
|
|
||||||
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
|
|
||||||
class:bg-accent={form.icon === name}
|
|
||||||
class:ring-2={form.icon === name}
|
|
||||||
class:ring-primary={form.icon === name}
|
|
||||||
onclick={() => (form.icon = name)}
|
|
||||||
>
|
|
||||||
<FlagIcon {name} color={form.color} class="size-4" />
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-sm text-destructive">{error}</p>
|
<p class="text-sm text-destructive">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
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 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 { positionAtDistance } from '$lib/gpx'
|
||||||
import { app } from '$lib/stores/app.svelte'
|
import { app } from '$lib/stores/app.svelte'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -19,6 +22,7 @@
|
||||||
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 DropdownMenu from '@/components/ui/dropdown-menu'
|
import * as DropdownMenu from '@/components/ui/dropdown-menu'
|
||||||
|
import * as Select from '@/components/ui/select'
|
||||||
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
|
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
|
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
|
||||||
|
|
@ -26,7 +30,7 @@
|
||||||
} from 'lucide-svelte'
|
} from 'lucide-svelte'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { browser } from '$app/environment'
|
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
|
// Generics der JSON-Felder, wie in trails/+page.svelte — die
|
||||||
// Rohdaten kommen ungetypt aus PocketBase zurück.
|
// Rohdaten kommen ungetypt aus PocketBase zurück.
|
||||||
|
|
@ -68,20 +72,37 @@
|
||||||
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
|
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
|
||||||
const trailMarkers = $derived(trail ? markers.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
|
// Für die Karte aufbereitete Marker — inklusive der Angaben fürs Popup
|
||||||
// (was, wann, wer). Formatiert wird hier, damit die Karte keine Stores
|
// (was, wann, wer). Formatiert wird hier, damit die Karte keine Stores
|
||||||
// und keine Datumslogik braucht.
|
// und keine Datumslogik braucht.
|
||||||
const mapMarkers = $derived(
|
const mapMarkers = $derived(
|
||||||
trailMarkers.map((m) => {
|
trailMarkers.map((m) => {
|
||||||
const flag = flags.getById(m.flag)
|
const look = markerLook(m)
|
||||||
return {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
lat: m.lat,
|
lat: m.lat,
|
||||||
lng: m.lng,
|
lng: m.lng,
|
||||||
color: flag?.color ?? '#64748b',
|
color: look.color,
|
||||||
resolved: !!m.resolved,
|
resolved: !!m.resolved,
|
||||||
label: flag?.label ?? 'Marker',
|
label: look.label,
|
||||||
icon: flag?.icon,
|
icon: look.icon,
|
||||||
note: m.note,
|
note: m.note,
|
||||||
when: formatDate(m.created),
|
when: formatDate(m.created),
|
||||||
who: markers.authorName(m),
|
who: markers.authorName(m),
|
||||||
|
|
@ -134,23 +155,44 @@
|
||||||
let markerDialog = $state(false)
|
let markerDialog = $state(false)
|
||||||
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
|
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
|
||||||
let markerDistance = $state<number | null>(null)
|
let markerDistance = $state<number | null>(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 markerNote = $state('')
|
||||||
let markerError = $state<string | null>(null)
|
let markerError = $state<string | null>(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 }) {
|
function onPlace(p: { lat: number; lng: number; distance: number | null }) {
|
||||||
if (!canPlace) return
|
if (!canPlace) return
|
||||||
|
|
||||||
markerCoords = { lat: p.lat, lng: p.lng }
|
markerCoords = { lat: p.lat, lng: p.lng }
|
||||||
markerDistance = p.distance
|
markerDistance = p.distance
|
||||||
markerFlag = flags.scoped[0]?.id ?? ''
|
markerFlag = flags.scoped[0]?.id ?? CUSTOM
|
||||||
|
markerLabel = ''
|
||||||
|
markerIcon = flags.scoped[0]?.icon ?? ''
|
||||||
markerNote = ''
|
markerNote = ''
|
||||||
markerError = null
|
markerError = null
|
||||||
markerDialog = true
|
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 }) {
|
function removeMarker(m: { id: string; note?: string }) {
|
||||||
app.confirm.request({
|
app.confirm.request({
|
||||||
title: 'Marker löschen?',
|
title: 'Marker löschen?',
|
||||||
|
|
@ -187,8 +229,9 @@
|
||||||
|
|
||||||
async function saveMarker() {
|
async function saveMarker() {
|
||||||
if (!trail || !markerCoords) return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,6 +239,10 @@
|
||||||
await markers.create({
|
await markers.create({
|
||||||
trail: trail.id,
|
trail: trail.id,
|
||||||
flag: markerFlag,
|
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,
|
lat: markerCoords.lat,
|
||||||
lng: markerCoords.lng,
|
lng: markerCoords.lng,
|
||||||
note: markerNote,
|
note: markerNote,
|
||||||
|
|
@ -210,7 +257,9 @@
|
||||||
|
|
||||||
// --- Kommentare ------------------------------------------------------
|
// --- Kommentare ------------------------------------------------------
|
||||||
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
|
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
|
||||||
let comments = $state<TrailCommentsResponse[]>([])
|
type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }>
|
||||||
|
|
||||||
|
let comments = $state<Comment[]>([])
|
||||||
let commentText = $state('')
|
let commentText = $state('')
|
||||||
let commentError = $state<string | null>(null)
|
let commentError = $state<string | null>(null)
|
||||||
let unsubComments: (() => void) | null = null
|
let unsubComments: (() => void) | null = null
|
||||||
|
|
@ -222,20 +271,30 @@
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
api.collection('trail_comments')
|
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) => {
|
.then((r) => {
|
||||||
if (!cancelled) comments = r
|
if (!cancelled) comments = r as Comment[]
|
||||||
})
|
})
|
||||||
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
|
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
|
||||||
|
|
||||||
api.collection('trail_comments')
|
api.collection('trail_comments')
|
||||||
.subscribe('*', (e) => {
|
.subscribe(
|
||||||
|
'*',
|
||||||
|
(e) => {
|
||||||
if (e.record.trail !== id) return
|
if (e.record.trail !== id) return
|
||||||
const idx = comments.findIndex((c) => c.id === e.record.id)
|
const record = e.record as Comment
|
||||||
if (e.action === 'create' && idx === -1) comments = [e.record, ...comments]
|
const idx = comments.findIndex((c) => c.id === record.id)
|
||||||
else if (e.action === 'update' && idx !== -1) comments[idx] = e.record
|
if (e.action === 'create' && idx === -1) comments = [record, ...comments]
|
||||||
else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== e.record.id)
|
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) => {
|
.then((u) => {
|
||||||
if (cancelled) u()
|
if (cancelled) u()
|
||||||
else unsubComments = u
|
else unsubComments = u
|
||||||
|
|
@ -422,28 +481,21 @@
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-3">
|
<CardContent class="space-y-3">
|
||||||
{#if canPlace}
|
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
|
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
|
||||||
Marker zu setzen.
|
Marker zu setzen.
|
||||||
</p>
|
</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 trailMarkers.length === 0}
|
{#if trailMarkers.length === 0}
|
||||||
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
|
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="divide-y -mx-2">
|
<ul class="divide-y -mx-2">
|
||||||
{#each trailMarkers as m (m.id)}
|
{#each trailMarkers as m (m.id)}
|
||||||
{@const flag = flags.getById(m.flag)}
|
{@const look = markerLook(m)}
|
||||||
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
|
<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" />
|
<FlagIcon name={look.icon} color={look.color} class="size-5" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
|
<p class="text-sm font-medium">{look.label}</p>
|
||||||
{#if m.note}
|
{#if m.note}
|
||||||
<p class="text-sm text-muted-foreground">{m.note}</p>
|
<p class="text-sm text-muted-foreground">{m.note}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -513,9 +565,20 @@
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="divide-y -mx-2">
|
<ul class="divide-y -mx-2">
|
||||||
{#each comments as c (c.id)}
|
{#each comments as c (c.id)}
|
||||||
<li class="px-2 py-3">
|
{@const author = c.expand?.created_by}
|
||||||
|
<li class="flex gap-3 px-2 py-3">
|
||||||
|
<!-- Nur das Bild; wer dahintersteckt, sagt der Tooltip. -->
|
||||||
|
<span
|
||||||
|
use:tooltip={{
|
||||||
|
content: author?.name || author?.email || 'Unbekannt',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar user={author} size={28} />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm">{c.text}</p>
|
<p class="text-sm">{c.text}</p>
|
||||||
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -646,19 +709,37 @@
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Typ</Label>
|
<Label>Typ</Label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<Select.Root type="single" value={markerFlag} onValueChange={selectFlag}>
|
||||||
|
<Select.Trigger class="w-full">
|
||||||
|
{#if selectedFlag}
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="size-2 rounded-full" style:background={selectedFlag.color}></span>
|
||||||
|
{selectedFlag.label}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Eigener Typ
|
||||||
|
{/if}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Item value={CUSTOM}>Eigener Typ …</Select.Item>
|
||||||
{#each flags.scoped as f (f.id)}
|
{#each flags.scoped as f (f.id)}
|
||||||
<Button
|
<Select.Item value={f.id}>
|
||||||
variant={markerFlag === f.id ? 'default' : 'outline'}
|
<span class="size-2 rounded-full mr-2 shrink-0" style:background={f.color}></span>
|
||||||
size="sm"
|
|
||||||
onclick={() => (markerFlag = f.id)}
|
|
||||||
>
|
|
||||||
<span class="size-2 rounded-full mr-2" style:background={f.color}></span>
|
|
||||||
{f.label}
|
{f.label}
|
||||||
</Button>
|
</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !markerFlag}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="marker-label">Bezeichnung</Label>
|
||||||
|
<Input id="marker-label" bind:value={markerLabel} placeholder="z. B. Wespennest am Anlieger" />
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<IconPicker id="marker-icon" bind:value={markerIcon} color={markerColor} />
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="marker-note">Notiz</Label>
|
<Label for="marker-note">Notiz</Label>
|
||||||
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />
|
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue