Laenge und Hoehenmeter tragen dieselben Icons wie der Kopf der Trail-Seite. Dazu kommen Anzahl der Marker und Kommentare - und ein Punkt daneben, wenn davon etwas aus den letzten 14 Tagen stammt. Die blosse Zahl sagt nichts darueber, ob sich gerade etwas tut. Kommentare bekommen dafuer einen eigenen Store. Sie lagen bisher direkt in der Trail-Seite, was richtig war, solange es sie nur dort gab; fuer die Liste waere ein zweiter Ladeweg auf dieselben Daten einer zu viel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
158 lines
4.6 KiB
TypeScript
158 lines
4.6 KiB
TypeScript
import { getContext, setContext } from 'svelte'
|
|
import { api, auth } from './pocketbase.svelte'
|
|
import { getTeamContext } from './teams.svelte'
|
|
import { isRecent } from '$lib/time'
|
|
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))
|
|
}
|
|
|
|
/** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */
|
|
hasRecent(trailId: string, days?: number): boolean {
|
|
return this.byTrail(trailId).some((r) => isRecent(r.created, days))
|
|
}
|
|
|
|
/** 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)
|
|
}
|