stammtisch-hersbruck/frontend/src/lib/stores/trailMarkers.svelte.ts
Daniel Michelberger fe8e301e65 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
2026-09-02 13:13:22 +02:00

152 lines
4.4 KiB
TypeScript

import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('trailMarker')
/**
* Marker samt aufgelöstem Melder. Ob `expand.created_by` gefüllt ist, hängt
* an den Sichtbarkeitsregeln der users-Collection — deshalb optional.
*/
export type TrailMarker = Omit<TrailMarkersResponse, 'expand'> & {
expand?: { created_by?: UsersResponse }
}
export class TrailMarkerStore {
records = $state<TrailMarker[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
/** Alle Marker eines Trails, jüngste zuerst */
byTrail(trailId: string): TrailMarker[] {
return this.records
.filter((r) => r.trail === trailId)
.sort((a, b) => (a.created < b.created ? 1 : -1))
}
/** Nur die offenen — was auf der Karte auffallen soll */
openByTrail(trailId: string): TrailMarker[] {
return this.byTrail(trailId).filter((r) => !r.resolved)
}
/**
* Ändern darf, wer den Marker angelegt hat, sowie Trail-Paten und
* Team-Admins — dieselbe Regel wie in der updateRule der Collection.
* Der Trail wird übergeben, weil der Store ihn nicht kennt.
*/
canEdit(marker: TrailMarker, stewards: string[], teamOwner?: string, teamAdmins: string[] = []): boolean {
const uid = auth.user?.id
if (!uid) return false
return (
marker.created_by === uid ||
stewards.includes(uid) ||
teamOwner === uid ||
teamAdmins.includes(uid)
)
}
/**
* Wer den Marker gemeldet hat. Fremde User-Datensätze sind derzeit nicht
* sichtbar (users.listRule: `id = @request.auth.id`), deshalb bleibt der
* Name bei Meldungen anderer offen.
*/
authorName(marker: TrailMarker): string {
const name = marker.expand?.created_by?.name
if (name) return name
if (marker.created_by && marker.created_by === auth.user?.id) return 'Du'
return 'Unbekannt'
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('trail_markers').getFullList({
sort: '-created',
expand: 'created_by',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Marker'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('trail_markers').subscribe('*', (e) => {
// Realtime liefert den Datensatz ungetypt; angefordert wird unten
// dasselbe expand wie beim Laden.
const record = e.record as TrailMarker
const idx = this.records.findIndex((r) => r.id === record.id)
if (e.action === 'create' && idx === -1) this.records = [record, ...this.records]
else if (e.action === 'update' && idx !== -1) this.records[idx] = record
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}, { expand: 'created_by' }).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('trail_markers subscribe failed:', e))
}
/**
* `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')
return await api.collection('trail_markers').create({
...data,
team: teamId,
resolved: false,
created_by: auth.user?.id,
})
}
async edit(id: string, data: Partial<TrailMarker>) {
return await api.collection('trail_markers').update(id, data)
}
/** Erledigt statt gelöscht — die Meldung bleibt als Historie erhalten. */
async toggleResolved(id: string) {
const marker = this.records.find((r) => r.id === id)
if (!marker) throw new Error('Marker nicht gefunden')
await api.collection('trail_markers').update(id, { resolved: !marker.resolved })
}
async remove(id: string) {
await api.collection('trail_markers').delete(id)
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setTrailMarkerContext() {
const store = new TrailMarkerStore()
setContext(KEY, store)
return store
}
export function getTrailMarkerContext(): TrailMarkerStore {
return getContext<TrailMarkerStore>(KEY)
}