feat: Stores für Trails und GPX-Versionen

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) <noreply@anthropic.com>
This commit is contained in:
Daniel Michelberger 2026-08-06 17:37:50 +02:00
parent efadbab48a
commit 247ac8ec6b
2 changed files with 239 additions and 0 deletions

View file

@ -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<TrailVersionsResponse[]>([])
loading = $state(false)
error = $state<string | null>(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<TrailVersionStore>(KEY)
}

View file

@ -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<TrailsResponse[]>([])
loading = $state(false)
error = $state<string | null>(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<TrailsResponse>) {
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<TrailStore>(KEY)
}