Die SvelteKit-App liegt unter frontend/, das Backend unter backend/ als eigenständige PocketBase-Instanz mit Dockerfile, docker-compose und versioniertem Schema. - PocketBase-URL über PUBLIC_PB_URL konfigurierbar, Default bleibt die produktive Instanz https://api.stammtisch-hersbruck.de - Schema als Snapshot-Migration der sechs fachlichen Collections (users, teams, events, runs, riders, times), abgezogen von der produktiven Instanz. Verifiziert: ein Erststart gegen leere pb_data legt alle sechs an, Felder und API-Rules stimmen überein. - pb_data und pb_migrations als Bind-Mounts, damit Daten persistieren und im Admin-UI erzeugte Migrationen im Repo landen - Veraltete Dokumentation entfernt: pocketbase_schema.json nannte Collections (stages, results, organizers), die es nicht gibt Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
200 lines
6.9 KiB
TypeScript
200 lines
6.9 KiB
TypeScript
import { getContext, setContext } from 'svelte'
|
|
import { api, auth } from './pocketbase.svelte'
|
|
import { app } from './app.svelte'
|
|
import type { TeamsResponse, UsersResponse } from '$lib/types'
|
|
|
|
const KEY = Symbol('team')
|
|
const LS_KEY = 'activeTeamId'
|
|
|
|
export class TeamStore {
|
|
records = $state<TeamsResponse[]>([])
|
|
activeId = $state<string | null>(null)
|
|
loading = $state(false)
|
|
error = $state<string | null>(null)
|
|
|
|
private unsubscribe: (() => void) | null = null
|
|
|
|
get active(): TeamsResponse | null {
|
|
return this.records.find((t) => t.id === this.activeId) ?? null
|
|
}
|
|
|
|
/** ID des aktuell eingeloggten Users */
|
|
private get me(): string | null {
|
|
return api.authStore.record?.id ?? null
|
|
}
|
|
|
|
isOwner(team: TeamsResponse | null = this.active): boolean {
|
|
if (!team || !this.me) return false
|
|
return team.owner === this.me
|
|
}
|
|
|
|
isAdmin(team: TeamsResponse | null = this.active): boolean {
|
|
if (!team || !this.me) return false
|
|
if (this.isOwner(team)) return true
|
|
return (team.admins ?? []).includes(this.me)
|
|
}
|
|
|
|
isMember(team: TeamsResponse | null = this.active): boolean {
|
|
if (!team || !this.me) return false
|
|
return (team.users ?? []).includes(this.me)
|
|
}
|
|
|
|
async load() {
|
|
this.loading = true
|
|
this.error = null
|
|
try {
|
|
this.records = await api.collection('teams').getFullList({
|
|
sort: '+name',
|
|
requestKey: null,
|
|
})
|
|
|
|
// Aktives Team aus localStorage wiederherstellen, sonst erstes nehmen
|
|
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(LS_KEY) : null
|
|
if (stored && this.records.some((t) => t.id === stored)) {
|
|
this.activeId = stored
|
|
} else if (this.records.length > 0) {
|
|
this.activeId = this.records[0].id
|
|
} else {
|
|
this.activeId = null
|
|
}
|
|
} catch (e: any) {
|
|
this.error = e.message ?? 'Fehler beim Laden der Teams'
|
|
console.error(e)
|
|
} finally {
|
|
this.loading = false
|
|
}
|
|
}
|
|
|
|
subscribe() {
|
|
if (this.unsubscribe) return
|
|
api.collection('teams').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].sort((a, b) => a.name.localeCompare(b.name))
|
|
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)
|
|
if (this.activeId === e.record.id) {
|
|
this.activeId = this.records[0]?.id ?? null
|
|
}
|
|
}
|
|
}).then((unsub) => {
|
|
this.unsubscribe = unsub
|
|
}).catch((e) => console.warn('teams subscribe failed:', e))
|
|
}
|
|
|
|
setActive(id: string) {
|
|
if (!this.records.some((t) => t.id === id)) return
|
|
this.activeId = id
|
|
if (typeof localStorage !== 'undefined') localStorage.setItem(LS_KEY, id)
|
|
}
|
|
|
|
async create(name: string) {
|
|
if (!this.me) throw new Error('Nicht eingeloggt')
|
|
const team = await api.collection('teams').create({
|
|
name,
|
|
owner: this.me,
|
|
users: [this.me],
|
|
admins: [],
|
|
})
|
|
// sofort als aktiv setzen
|
|
setTimeout(() => this.setActive(team.id), 0)
|
|
return team
|
|
}
|
|
|
|
async rename(id: string, name: string) {
|
|
return await api.collection('teams').update(id, { name })
|
|
}
|
|
|
|
/** Mitglied hinzufügen (Owner/Admin only) */
|
|
async addMember(teamId: string, userId: string) {
|
|
const team = this.records.find((t) => t.id === teamId)
|
|
if (!team) throw new Error('Team nicht gefunden')
|
|
const users = [...(team.users ?? [])]
|
|
if (!users.includes(userId)) users.push(userId)
|
|
return await api.collection('teams').update(teamId, { users })
|
|
}
|
|
|
|
/** Mitglied entfernen */
|
|
async removeMember(teamId: string, userId: string) {
|
|
const team = this.records.find((t) => t.id === teamId)
|
|
if (!team) throw new Error('Team nicht gefunden')
|
|
const users = (team.users ?? []).filter((u) => u !== userId)
|
|
const admins = (team.admins ?? []).filter((u) => u !== userId)
|
|
return await api.collection('teams').update(teamId, { users, admins })
|
|
}
|
|
|
|
async promoteToAdmin(teamId: string, userId: string) {
|
|
const team = this.records.find((t) => t.id === teamId)
|
|
if (!team) throw new Error('Team nicht gefunden')
|
|
const admins = [...(team.admins ?? [])]
|
|
if (!admins.includes(userId)) admins.push(userId)
|
|
return await api.collection('teams').update(teamId, { admins })
|
|
}
|
|
|
|
async demoteFromAdmin(teamId: string, userId: string) {
|
|
const team = this.records.find((t) => t.id === teamId)
|
|
if (!team) throw new Error('Team nicht gefunden')
|
|
const admins = (team.admins ?? []).filter((u) => u !== userId)
|
|
return await api.collection('teams').update(teamId, { admins })
|
|
}
|
|
|
|
/** Verlassen — entfernt sich selbst aus users + admins */
|
|
async leave(teamId: string) {
|
|
if (!this.me) return
|
|
return await this.removeMember(teamId, this.me)
|
|
}
|
|
|
|
/** Team löschen (nur Owner) */
|
|
remove(team: TeamsResponse) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
app.confirm.request({
|
|
title: 'Team löschen?',
|
|
text: `Möchtest du das Team „${team.name}" wirklich löschen? Achtung: zugehörige Events, Fahrer und Zeiten werden NICHT gelöscht, sind aber für niemanden mehr sichtbar.`,
|
|
yes: async () => {
|
|
try {
|
|
await api.collection('teams').delete(team.id)
|
|
app.confirm.close()
|
|
resolve()
|
|
} catch (e) {
|
|
app.confirm.close()
|
|
reject(e)
|
|
}
|
|
},
|
|
no: () => {
|
|
app.confirm.close()
|
|
resolve()
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
/** User per E-Mail suchen (für Einladungs-UI) */
|
|
async findUserByEmail(email: string): Promise<UsersResponse | null> {
|
|
try {
|
|
const res = await api.collection('users').getFirstListItem(
|
|
`email = "${email.replaceAll('"', '')}"`,
|
|
{ requestKey: null },
|
|
)
|
|
return res
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (this.unsubscribe) {
|
|
this.unsubscribe()
|
|
this.unsubscribe = null
|
|
}
|
|
}
|
|
}
|
|
|
|
export function setTeamContext() {
|
|
const store = new TeamStore()
|
|
setContext(KEY, store)
|
|
return store
|
|
}
|
|
|
|
export function getTeamContext(): TeamStore {
|
|
return getContext<TeamStore>(KEY)
|
|
}
|