diff --git a/frontend/src/lib/stores/trailFlags.svelte.ts b/frontend/src/lib/stores/trailFlags.svelte.ts new file mode 100644 index 0000000..5d87695 --- /dev/null +++ b/frontend/src/lib/stores/trailFlags.svelte.ts @@ -0,0 +1,121 @@ +import { getContext, setContext } from 'svelte' +import { api } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import type { TrailFlagsResponse } from '$lib/types' + +const KEY = Symbol('trailFlag') + +/** + * Vorschlag für einen Standardsatz. Bewusst NICHT in der Migration: Seed-Daten + * dort kämen bei jedem Containerstart zurück, auch nachdem sie jemand gelöscht + * hat. Stattdessen legt sie die Flag-Verwaltung auf Knopfdruck an. + * + * icon: Name eines Lucide-Icons (lucide-svelte) + */ +export const DEFAULT_FLAGS = [ + { label: 'Baum quer', icon: 'TreePine', color: '#ca8a04', severity: 'warnung' }, + { label: 'Verblockt', icon: 'Blocks', color: '#ea580c', severity: 'warnung' }, + { label: 'Erosion', icon: 'Waves', color: '#a16207', severity: 'warnung' }, + { label: 'Sperrung', icon: 'Ban', color: '#dc2626', severity: 'kritisch' }, + { label: 'Bauarbeiten', icon: 'Construction', color: '#dc2626', severity: 'kritisch' }, + { label: 'Hinweis', icon: 'Info', color: '#2563eb', severity: 'info' }, +] + +export class TrailFlagStore { + records = $state([]) + loading = $state(false) + error = $state(null) + + private unsubscribe: (() => void) | null = null + private teams = getTeamContext() + + get scoped(): TrailFlagsResponse[] { + const teamId = this.teams.activeId + if (!teamId) return [] + return this.records.filter((r) => r.team === teamId) + } + + getById(id: string): TrailFlagsResponse | undefined { + return this.records.find((r) => r.id === id) + } + + async load() { + this.loading = true + this.error = null + try { + this.records = await api.collection('trail_flags').getFullList({ + sort: 'label', + requestKey: null, + }) + } catch (e: any) { + this.error = e.message ?? 'Fehler beim Laden der Flags' + console.error(e) + } finally { + this.loading = false + } + } + + subscribe() { + if (this.unsubscribe) return + api.collection('trail_flags').subscribe('*', (e) => { + const idx = this.records.findIndex((r) => r.id === e.record.id) + if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record] + else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record + else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) + }).then((unsub) => { + this.unsubscribe = unsub + }).catch((e) => console.warn('trail_flags subscribe failed:', e)) + } + + async create(data: { label: string; icon: string; color: string; severity: string }) { + const teamId = this.teams.activeId + if (!teamId) throw new Error('Kein aktives Team') + return await api.collection('trail_flags').create({ ...data, team: teamId }) + } + + async edit(id: string, data: Partial) { + return await api.collection('trail_flags').update(id, data) + } + + async remove(id: string) { + await api.collection('trail_flags').delete(id) + } + + /** + * Legt die Standard-Flags an, die noch fehlen. Vorhandene bleiben + * unangetastet — der Knopf lässt sich also gefahrlos mehrfach drücken. + * Gibt die Anzahl neu angelegter Flags zurück. + */ + async seedDefaults(): Promise { + const teamId = this.teams.activeId + if (!teamId) throw new Error('Kein aktives Team') + + const existing = new Set(this.scoped.map((f) => f.label)) + let created = 0 + + for (const flag of DEFAULT_FLAGS) { + if (existing.has(flag.label)) continue + await api.collection('trail_flags').create({ ...flag, team: teamId }) + created++ + } + + return created + } + + destroy() { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + } +} + +export function setTrailFlagContext() { + const store = new TrailFlagStore() + setContext(KEY, store) + return store +} + +export function getTrailFlagContext(): TrailFlagStore { + return getContext(KEY) +} diff --git a/frontend/src/lib/stores/trailMarkers.svelte.ts b/frontend/src/lib/stores/trailMarkers.svelte.ts new file mode 100644 index 0000000..dbaf98c --- /dev/null +++ b/frontend/src/lib/stores/trailMarkers.svelte.ts @@ -0,0 +1,99 @@ +import { getContext, setContext } from 'svelte' +import { api, auth } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import type { TrailMarkersResponse } from '$lib/types' + +const KEY = Symbol('trailMarker') + +export class TrailMarkerStore { + records = $state([]) + loading = $state(false) + error = $state(null) + + private unsubscribe: (() => void) | null = null + private teams = getTeamContext() + + /** Alle Marker eines Trails, jüngste zuerst */ + byTrail(trailId: string): TrailMarkersResponse[] { + 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): TrailMarkersResponse[] { + return this.byTrail(trailId).filter((r) => !r.resolved) + } + + async load() { + this.loading = true + this.error = null + try { + this.records = await api.collection('trail_markers').getFullList({ + sort: '-created', + 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) => { + const idx = this.records.findIndex((r) => r.id === e.record.id) + if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] + else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record + else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) + }).then((unsub) => { + this.unsubscribe = unsub + }).catch((e) => console.warn('trail_markers subscribe failed:', e)) + } + + async create(data: { trail: string; flag: 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) { + 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(KEY) +}