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:
parent
efadbab48a
commit
247ac8ec6b
2 changed files with 239 additions and 0 deletions
120
frontend/src/lib/stores/trailVersions.svelte.ts
Normal file
120
frontend/src/lib/stores/trailVersions.svelte.ts
Normal 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)
|
||||
}
|
||||
119
frontend/src/lib/stores/trails.svelte.ts
Normal file
119
frontend/src/lib/stores/trails.svelte.ts
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue