From 247ac8ec6bb2fe65c6e990ff450a9a3a1c24d032 Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Thu, 6 Aug 2026 17:37:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Stores=20f=C3=BCr=20Trails=20und=20GPX-?= =?UTF-8?q?Versionen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrailStore verwaltet Trail-Datensätze mit Team-Filterung und Edit-Berechtigungen für Trail-Paten, Team-Owner und Admins. TrailVersionStore verwaltet GPX-Versionen mit Upload und Aktivierung (mit Prüfung auf Trail-Zugehörigkeit). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/stores/trailVersions.svelte.ts | 120 ++++++++++++++++++ frontend/src/lib/stores/trails.svelte.ts | 119 +++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 frontend/src/lib/stores/trailVersions.svelte.ts create mode 100644 frontend/src/lib/stores/trails.svelte.ts diff --git a/frontend/src/lib/stores/trailVersions.svelte.ts b/frontend/src/lib/stores/trailVersions.svelte.ts new file mode 100644 index 0000000..6e79b0f --- /dev/null +++ b/frontend/src/lib/stores/trailVersions.svelte.ts @@ -0,0 +1,120 @@ +import { getContext, setContext } from 'svelte' +import { api, auth } from './pocketbase.svelte' +import { parseGpx } from '$lib/gpx' +import type { TrailVersionsResponse } from '$lib/types' + +const KEY = Symbol('trailVersion') + +/** Größer als das maxSize des gpx-Feldes wäre serverseitig ohnehin abgelehnt. */ +const MAX_GPX_BYTES = 10 * 1024 * 1024 + +export class TrailVersionStore { + records = $state([]) + loading = $state(false) + error = $state(null) + + private unsubscribe: (() => void) | null = null + + /** Versionen eines Trails, jüngste zuerst */ + byTrail(trailId: string): TrailVersionsResponse[] { + return this.records + .filter((r) => r.trail === trailId) + .sort((a, b) => (a.created < b.created ? 1 : -1)) + } + + async load() { + this.loading = true + this.error = null + try { + this.records = await api.collection('trail_versions').getFullList({ + sort: '-created', + requestKey: null, + }) + } catch (e: any) { + this.error = e.message ?? 'Fehler beim Laden der Versionen' + console.error(e) + } finally { + this.loading = false + } + } + + subscribe() { + if (this.unsubscribe) return + api.collection('trail_versions').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_versions subscribe failed:', e)) + } + + /** + * Liest die GPX-Datei im Browser, legt eine Version an und macht sie zur + * aktiven. Die Originaldatei wird mitgespeichert, damit sie später + * heruntergeladen werden kann. + */ + async upload(trailId: string, file: File, note: string) { + if (file.size > MAX_GPX_BYTES) { + throw new Error('Die Datei ist größer als 10 MB.') + } + + const text = await file.text() + const parsed = parseGpx(text) // wirft mit verständlicher Meldung + + const form = new FormData() + form.append('trail', trailId) + form.append('gpx', file) + form.append('geojson', JSON.stringify(parsed.geojson)) + form.append('elevation', JSON.stringify(parsed.elevation)) + form.append('bounds', JSON.stringify(parsed.bounds)) + form.append('distance_m', String(parsed.distance_m)) + form.append('ascent_m', String(parsed.ascent_m)) + form.append('note', note) + if (auth.user?.id) form.append('uploaded_by', auth.user.id) + + const version = await api.collection('trail_versions').create(form) + + // Eine frisch hochgeladene Version ist immer die aktive. + await api.collection('trails').update(trailId, { current: version.id }) + + return version + } + + /** + * Schaltet auf eine ältere Version zurück. Prüft vorher, dass die Version + * zu diesem Trail gehört — trails.current könnte sonst auf eine fremde + * Version zeigen. + */ + async activate(trailId: string, versionId: string) { + const version = this.records.find((r) => r.id === versionId) + if (!version) throw new Error('Version nicht gefunden') + if (version.trail !== trailId) { + throw new Error('Die Version gehört nicht zu diesem Trail') + } + + await api.collection('trails').update(trailId, { current: versionId }) + } + + async remove(id: string) { + await api.collection('trail_versions').delete(id) + } + + destroy() { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + } +} + +export function setTrailVersionContext() { + const store = new TrailVersionStore() + setContext(KEY, store) + return store +} + +export function getTrailVersionContext(): TrailVersionStore { + return getContext(KEY) +} diff --git a/frontend/src/lib/stores/trails.svelte.ts b/frontend/src/lib/stores/trails.svelte.ts new file mode 100644 index 0000000..5b4d081 --- /dev/null +++ b/frontend/src/lib/stores/trails.svelte.ts @@ -0,0 +1,119 @@ +import { getContext, setContext } from 'svelte' +import { api } from './pocketbase.svelte' +import { auth } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import type { TrailsResponse } from '$lib/types' + +const KEY = Symbol('trail') + +export class TrailStore { + records = $state([]) + loading = $state(false) + error = $state(null) + + private unsubscribe: (() => void) | null = null + private teams = getTeamContext() + + /** Records gefiltert nach aktivem Team */ + get scoped(): TrailsResponse[] { + const teamId = this.teams.activeId + if (!teamId) return [] + return this.records.filter((r) => r.team === teamId) + } + + getById(id: string): TrailsResponse | undefined { + return this.records.find((r) => r.id === id) + } + + /** + * Bearbeiten dürfen Trail-Paten sowie Owner und Admins des Teams — + * dieselbe Regel wie in der updateRule der Collection. Die Prüfung hier + * blendet nur UI aus; durchgesetzt wird sie serverseitig. + */ + canEdit(trail: TrailsResponse): boolean { + const uid = auth.user?.id + if (!uid) return false + + const team = this.teams.records.find((t) => t.id === trail.team) + const stewards = (trail.stewards ?? []) as string[] + + return ( + stewards.includes(uid) || + team?.owner === uid || + ((team?.admins ?? []) as string[]).includes(uid) + ) + } + + async load() { + this.loading = true + this.error = null + try { + this.records = await api.collection('trails').getFullList({ + sort: 'name', + requestKey: null, + }) + } catch (e: any) { + this.error = e.message ?? 'Fehler beim Laden der Trails' + console.error(e) + } finally { + this.loading = false + } + } + + subscribe() { + if (this.unsubscribe) return + api.collection('trails').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('trails subscribe failed:', e)) + } + + async create(data: { name: string; description?: string; status?: string }) { + const teamId = this.teams.activeId + if (!teamId) throw new Error('Kein aktives Team') + + const uid = auth.user?.id + // Wer anlegt, wird automatisch erster Pate — sonst könnte niemand + // den frisch angelegten Trail bearbeiten. + return await api.collection('trails').create({ + ...data, + status: data.status ?? 'offen', + team: teamId, + created_by: uid, + stewards: uid ? [uid] : [], + }) + } + + async edit(id: string, data: Partial) { + return await api.collection('trails').update(id, data) + } + + async setStewards(id: string, userIds: string[]) { + return await api.collection('trails').update(id, { stewards: userIds }) + } + + async remove(id: string) { + await api.collection('trails').delete(id) + } + + destroy() { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + } +} + +export function setTrailContext() { + const store = new TrailStore() + setContext(KEY, store) + return store +} + +export function getTrailContext(): TrailStore { + return getContext(KEY) +}