From 7c3cf2935465536da2a05dfe5f413a905a229be0 Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Wed, 2 Sep 2026 12:56:01 +0200 Subject: [PATCH] feat: Einstellungen als Reiter, Fahrerverzeichnis, datierte Events Die Einstellungen bekommen Reiter: Profil, Team, Fahrer (nur Teamleitung) und Flag-Typen. /dashboard/teams wandert dorthin. Neu ist die Hauptroute Fahrer - das Verzeichnis des Teams, fuer alle sichtbar und fuer niemanden dort aenderbar. Angelegt, geaendert und mit einem Konto verknuepft wird in den Einstellungen; serverseitig setzen das die neuen Regeln der Collection durch. Ein Fahrer bekommt sein Login entweder aus den vorhandenen Teammitgliedern oder direkt neu angelegt. Die Einladung per E-Mail-Suche entfaellt: Sie konnte nie funktionieren, weil users.listRule nur den eigenen Datensatz freigibt - daher auch die abgeschnittenen IDs in der alten Mitgliederliste. Events zeigen statt eines Status ihr Datum, mit Checkbox fuer mehrtaegig. Gespeichert wird ein Enddatum nur, wenn es sich vom Beginn unterscheidet - es ist damit zugleich das Kennzeichen fuer mehrtaegig statt einer zweiten Wahrheit ueber dieselbe Sache. Listen gruppieren nach laufend, anstehend, ohne Termin und vorbei. Im Kopf teilen sich Name, Design-Umschalter und Logout eine Zeile, das Team-Dropdown sitzt darunter; das Logo passt jetzt in die Leiste, statt beschnitten zu wirken. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o --- .../src/lib/components/TeamSwitcher.svelte | 2 +- frontend/src/lib/stores/events.svelte.ts | 55 ++- frontend/src/lib/stores/riders.svelte.ts | 39 +- frontend/src/lib/stores/teams.svelte.ts | 64 +++ frontend/src/lib/time.ts | 42 ++ frontend/src/routes/dashboard/+layout.svelte | 110 +++-- frontend/src/routes/dashboard/+page.svelte | 27 +- .../src/routes/dashboard/events/+page.svelte | 96 +++-- .../routes/dashboard/events/[id]/+page.svelte | 64 ++- .../events/[id]/riders/[riderId]/+page.svelte | 51 ++- .../src/routes/dashboard/riders/+page.svelte | 143 +++++++ .../routes/dashboard/settings/+layout.svelte | 57 +++ .../routes/dashboard/settings/+page.svelte | 191 +++++++++ .../dashboard/settings/flags/+page.svelte | 39 +- .../dashboard/settings/riders/+page.svelte | 385 ++++++++++++++++++ .../dashboard/settings/team/+page.svelte | 329 +++++++++++++++ .../src/routes/dashboard/teams/+page.svelte | 341 ---------------- 17 files changed, 1568 insertions(+), 467 deletions(-) create mode 100644 frontend/src/routes/dashboard/riders/+page.svelte create mode 100644 frontend/src/routes/dashboard/settings/+layout.svelte create mode 100644 frontend/src/routes/dashboard/settings/+page.svelte create mode 100644 frontend/src/routes/dashboard/settings/riders/+page.svelte create mode 100644 frontend/src/routes/dashboard/settings/team/+page.svelte delete mode 100644 frontend/src/routes/dashboard/teams/+page.svelte diff --git a/frontend/src/lib/components/TeamSwitcher.svelte b/frontend/src/lib/components/TeamSwitcher.svelte index bbff610..ff4227b 100644 --- a/frontend/src/lib/components/TeamSwitcher.svelte +++ b/frontend/src/lib/components/TeamSwitcher.svelte @@ -35,7 +35,7 @@ {/each} - goto('/dashboard/teams')}> + goto('/dashboard/settings/team')}> Teams verwalten diff --git a/frontend/src/lib/stores/events.svelte.ts b/frontend/src/lib/stores/events.svelte.ts index 9896c70..554c340 100644 --- a/frontend/src/lib/stores/events.svelte.ts +++ b/frontend/src/lib/stores/events.svelte.ts @@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte' import { api } from './pocketbase.svelte' import { app } from './app.svelte' import { getTeamContext } from './teams.svelte' +import { dayOf, todayISO } from '$lib/time' import type { EventsResponse } from '$lib/types' const KEY = Symbol('event') @@ -87,14 +88,56 @@ export class EventStore { return this.records.find((r) => r.id === id) } - get active() { - return this.scoped.filter((r) => r.status === 'active') + /** + * Nach Datum sortiert, das nächste zuerst. Events ohne Datum hängen + * hinten dran — sie sind noch nicht terminiert. + */ + get byDate(): EventsResponse[] { + return [...this.scoped].sort((a, b) => { + const da = dayOf(a.starts) + const db = dayOf(b.starts) + if (!da && !db) return 0 + if (!da) return 1 + if (!db) return -1 + return da < db ? -1 : da > db ? 1 : 0 + }) } - get drafts() { - return this.scoped.filter((r) => r.status === 'draft') + + /** + * Läuft heute. Ohne Enddatum zählt der Starttag allein — genau dann ist + * ein eintägiges Event „heute". + */ + get active(): EventsResponse[] { + const today = todayISO() + return this.byDate.filter((r) => { + const from = dayOf(r.starts) + if (!from) return false + const to = dayOf(r.ends) || from + return from <= today && today <= to + }) } - get finished() { - return this.scoped.filter((r) => r.status === 'finished') + + /** Steht noch an. */ + get upcoming(): EventsResponse[] { + const today = todayISO() + return this.byDate.filter((r) => dayOf(r.starts) > today) + } + + /** Vorbei — der letzte Tag liegt hinter uns. */ + get past(): EventsResponse[] { + const today = todayISO() + return this.byDate + .filter((r) => { + const from = dayOf(r.starts) + if (!from) return false + return (dayOf(r.ends) || from) < today + }) + .reverse() + } + + /** Noch ohne Termin. */ + get undated(): EventsResponse[] { + return this.byDate.filter((r) => !dayOf(r.starts)) } destroy() { diff --git a/frontend/src/lib/stores/riders.svelte.ts b/frontend/src/lib/stores/riders.svelte.ts index d0fd349..fed8b33 100644 --- a/frontend/src/lib/stores/riders.svelte.ts +++ b/frontend/src/lib/stores/riders.svelte.ts @@ -2,7 +2,7 @@ import { getContext, setContext } from 'svelte' import { api } from './pocketbase.svelte' import { app } from './app.svelte' import { getTeamContext } from './teams.svelte' -import type { RidersResponse } from '$lib/types' +import type { RidersResponse, UsersResponse } from '$lib/types' const KEY = Symbol('rider') @@ -34,6 +34,15 @@ export class RiderStore { return this.records.filter((r) => r.team === teamId) } + /** + * Anlegen, ändern und löschen darf nur die Teamleitung — dieselbe Regel + * wie in createRule/updateRule/deleteRule der Collection. Die Prüfung + * hier blendet nur UI aus; durchgesetzt wird sie serverseitig. + */ + get canManage(): boolean { + return this.teams.isAdmin() + } + async load() { this.loading = true this.error = null @@ -102,6 +111,34 @@ export class RiderStore { return this.records.find((r) => r.id === id) } + /** Fahrer, der zu einem Login gehört — für „das bin ich"-Ansichten. */ + getByUser(userId: string) { + return this.scoped.filter((r) => r.user === userId) + } + + /** Bestehendes Konto zuordnen oder die Zuordnung lösen. */ + async linkUser(riderId: string, userId: string | null) { + return await this.edit(riderId, { user: userId ?? '' } as Partial) + } + + /** + * Legt dem Fahrer ein Login an und verknüpft es. Das Konto entsteht im + * Team-Store — es ist zuerst ein Teammitglied und erst dann dieser Fahrer. + */ + async createLogin( + rider: RidersResponse, + data: { email: string; password: string; name?: string }, + ): Promise { + const user = await this.teams.createMember({ + ...data, + name: data.name?.trim() || fullName(rider), + }) + + await this.linkUser(rider.id, user.id) + + return user + } + getByEvent(eventId: string) { return this.scoped.filter((r) => r.event === eventId) } diff --git a/frontend/src/lib/stores/teams.svelte.ts b/frontend/src/lib/stores/teams.svelte.ts index 9968197..4bb9790 100644 --- a/frontend/src/lib/stores/teams.svelte.ts +++ b/frontend/src/lib/stores/teams.svelte.ts @@ -12,6 +12,13 @@ export class TeamStore { loading = $state(false) error = $state(null) + /** + * Aufgelöste User-Datensätze, nach ID. Ein Team kennt nur die IDs seiner + * Mitglieder; die Namen holt sich die Oberfläche einzeln nach. Cache, + * damit dieselbe ID nicht auf jeder Seite erneut geladen wird. + */ + users = $state>({}) + private unsubscribe: (() => void) | null = null get active(): TeamsResponse | null { @@ -168,6 +175,63 @@ export class TeamStore { }) } + /** + * Einen User nachladen. Sichtbar ist ein fremder Datensatz nur über seine + * ID (users.viewRule); durchblättern lässt sich die Collection nicht. + */ + async loadUser(id: string): Promise { + if (this.users[id]) return this.users[id] + + try { + const u = await api.collection('users').getOne(id, { requestKey: null }) + this.users = { ...this.users, [id]: u } + return u + } catch { + return null + } + } + + /** Alle Mitglieder eines Teams in den Cache holen. */ + async loadMembers(team: TeamsResponse | null = this.active) { + if (!team) return + const ids = [...(team.users ?? []), team.owner].filter(Boolean) as string[] + await Promise.all(ids.filter((id) => !this.users[id]).map((id) => this.loadUser(id))) + } + + /** Anzeigename zu einer User-ID; fällt auf eine gekürzte ID zurück. */ + userLabel(id: string): string { + const u = this.users[id] + if (!u) return id.slice(0, 6) + '…' + return u.name || u.email || id.slice(0, 6) + '…' + } + + /** + * Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die + * REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das + * Konto bereits — der Aufrufer meldet den Fehler, statt still + * weiterzumachen. + * + * Erlaubt ist das seit 1754500500_riders_user_and_admin_rules.js jedem + * eingeloggten Nutzer (users.createRule); eine öffentliche Registrierung + * gibt es weiterhin nicht. + */ + async createMember(data: { email: string; password: string; name?: string }, teamId = this.activeId) { + if (!teamId) throw new Error('Kein aktives Team') + + const user = (await api.collection('users').create({ + email: data.email.trim(), + emailVisibility: false, + password: data.password, + passwordConfirm: data.password, + name: data.name?.trim() || data.email.trim(), + })) as UsersResponse + + this.users = { ...this.users, [user.id]: user } + await this.addMember(teamId, user.id) + + return user + } + /** User per E-Mail suchen (für Einladungs-UI) */ async findUserByEmail(email: string): Promise { try { diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts index bbf8f05..eacd705 100644 --- a/frontend/src/lib/time.ts +++ b/frontend/src/lib/time.ts @@ -28,3 +28,45 @@ export function formatDateTime(iso: string | undefined): string { if (!iso) return '—' return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) } + +/** Heutiges Datum als `JJJJ-MM-TT` in lokaler Zeit (nicht UTC). */ +export function todayISO(): string { + const d = new Date() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${day}` +} + +/** + * Datumsanteil eines PocketBase-Werts. Die kommen als + * `2026-09-12 00:00:00.000Z` zurück; für Vergleiche und `` + * zählt nur der Tag. + */ +export function dayOf(value: string | undefined): string { + if (!value) return '' + return value.slice(0, 10) +} + +/** Ein Tag als `TT.MM.JJJJ`. */ +export function formatDay(value: string | undefined): string { + const day = dayOf(value) + if (!day) return '' + + const [y, m, d] = day.split('-') + return `${d}.${m}.${y}` +} + +/** + * Zeitraum eines Events. Ohne Enddatum oder bei gleichem Tag steht nur ein + * Datum da; sonst „12.09.2026 – 14.09.2026". + */ +export function formatDateRange(starts: string | undefined, ends: string | undefined): string { + const from = formatDay(starts) + const to = formatDay(ends) + + if (!from && !to) return '' + if (!from) return to + if (!to || to === from) return from + + return `${from} – ${to}` +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 924fd2c..41162cb 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -16,7 +16,7 @@ import Logo from '$lib/components/Logo.svelte' import TeamSwitcher from '$lib/components/TeamSwitcher.svelte' import RunningTimesToast from '$lib/components/RunningTimesToast.svelte' - import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-svelte' + import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte' import { toggleMode, mode } from 'mode-watcher' import { onDestroy, onMount } from 'svelte' @@ -88,8 +88,9 @@ const nav = [ { href: '/dashboard', icon: Home, label: 'Dashboard' }, { href: '/dashboard/events', icon: Calendar, label: 'Events' }, + { href: '/dashboard/riders', icon: Bike, label: 'Fahrer' }, { href: '/dashboard/trails', icon: Route, label: 'Trails' }, - { href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' }, + { href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' }, ] @@ -97,42 +98,59 @@
-
-
- - - +
+ +
+ + +
- - +
-
diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index c22eefd..079862d 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' + import { formatDateRange } from '$lib/time' import { Calendar, Users, Clock, MapPin, Route, UsersRound } from 'lucide-svelte' const teams = getTeamContext() @@ -16,6 +17,10 @@ const riders = getRiderContext() const times = getTimeContext() + // „Aktiv" heißt jetzt: findet heute statt. Für die Startseite ist das + // zusammen mit dem Anstehenden interessant. + const upNext = $derived([...events.active, ...events.upcoming].slice(0, 4)) + const stats = $derived({ totalEvents: events.scoped.length, activeEvents: events.active.length, @@ -37,7 +42,7 @@ Team {teams.active.name} · {stats.totalEvents} Events, {stats.totalRiders} Fahrer

{:else} -

Kein Team aktiv. Team verwalten.

+

Kein Team aktiv. Team verwalten.

{/if}
@@ -49,7 +54,7 @@
{stats.totalEvents}
-

{stats.activeEvents} aktiv

+

{stats.activeEvents} heute

@@ -94,7 +99,7 @@ - +

Teams

Mitglieder und Rechte

@@ -104,27 +109,31 @@
-

Aktive Events

- {#if events.active.length === 0} +

Heute und demnächst

+ {#if upNext.length === 0} -

Keine aktiven Events

+

Nichts geplant

- Aktiviere ein Event über die Eventliste, um mit der Zeitnahme zu beginnen. + Trag einem Event ein Datum ein, dann steht es hier.

{:else}
- {#each events.active as event} + {#each upNext as event} {@const eventRuns = runs.getByEvent(event.id).length}
{event.name || event.description || 'Unbenannt'} - Aktiv + {#if event.starts} + + {formatDateRange(event.starts, event.ends)} + + {/if}
{#if event.location} diff --git a/frontend/src/routes/dashboard/events/+page.svelte b/frontend/src/routes/dashboard/events/+page.svelte index 391e38b..3e87e46 100644 --- a/frontend/src/routes/dashboard/events/+page.svelte +++ b/frontend/src/routes/dashboard/events/+page.svelte @@ -7,25 +7,24 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import * as Dialog from '@/components/ui/dialog' - import * as Select from '@/components/ui/select' + import { dayOf, formatDateRange } from '$lib/time' import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte' - import type { EventsResponse, EventsStatusOptions } from '$lib/types' + import type { EventsResponse } from '$lib/types' const events = getEventContext() const runs = getRunContext() let showDialog = $state(false) let editing = $state(null) - let form = $state<{ - name: string - description: string - location: string - status: EventsStatusOptions - }>({ name: '', description: '', location: '', status: 'draft' as EventsStatusOptions }) + let form = $state({ name: '', description: '', location: '', starts: '', ends: '' }) + let multiDay = $state(false) + let formError = $state(null) function openCreate() { editing = null - form = { name: '', description: '', location: '', status: 'draft' as EventsStatusOptions } + form = { name: '', description: '', location: '', starts: '', ends: '' } + multiDay = false + formError = null showDialog = true } @@ -35,29 +34,47 @@ name: e.name ?? '', description: e.description ?? '', location: e.location ?? '', - status: e.status ?? ('draft' as EventsStatusOptions), + starts: dayOf(e.starts), + ends: dayOf(e.ends), } + // Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein + // eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache. + multiDay = !!dayOf(e.ends) + formError = null showDialog = true } async function save() { if (!form.name.trim()) return + + if (multiDay && !form.starts) { + formError = 'Ohne Beginn ergibt ein Ende keinen Sinn.' + return + } + if (multiDay && form.ends && form.ends < form.starts) { + formError = 'Das Ende liegt vor dem Beginn.' + return + } + + formError = null try { - if (editing) await events.edit(editing.id, form) - else await events.create(form) + // Ein eintägiges Event hat kein Ende — sonst stünde überall + // dasselbe Datum zweimal. + const ends = multiDay && form.ends !== form.starts ? form.ends : '' + const data = { ...form, ends } + if (editing) await events.edit(editing.id, data) + else await events.create(data) showDialog = false } catch (e: any) { - alert(`Fehler: ${e.message}`) + formError = e.message ?? 'Das Event konnte nicht gespeichert werden.' } } - const statusLabels = { draft: 'Entwurf', active: 'Aktiv', finished: 'Abgeschlossen' } - const statusVariants = { draft: 'secondary', active: 'default', finished: 'outline' } as const - const groups = $derived([ - { title: 'Aktive Events', items: events.active, status: 'active' as const, showZero: false }, - { title: 'Entwürfe', items: events.drafts, status: 'draft' as const, showZero: false }, - { title: 'Abgeschlossen', items: events.finished, status: 'finished' as const, showZero: false }, + { title: 'Läuft gerade', items: events.active }, + { title: 'Anstehend', items: events.upcoming }, + { title: 'Ohne Termin', items: events.undated }, + { title: 'Vorbei', items: events.past }, ]) @@ -102,9 +119,11 @@
{event.name || 'Unbenannt'} - - {statusLabels[event.status as keyof typeof statusLabels] ?? 'Entwurf'} - + {#if event.starts} + + {formatDateRange(event.starts, event.ends)} + + {/if}
{#if event.location} @@ -168,19 +187,30 @@
- - - - {statusLabels[form.status]} - - - Entwurf - Aktiv - Abgeschlossen - - + +
+ + + {#if multiDay} +
+ + +
+ {/if} + + {#if formError} +

{formError}

+ {/if} + diff --git a/frontend/src/routes/dashboard/events/[id]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/+page.svelte index bea292a..aa1200c 100644 --- a/frontend/src/routes/dashboard/events/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/+page.svelte @@ -5,6 +5,8 @@ import { getRunContext } from '$lib/stores/runs.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte' import { getTimeContext } from '$lib/stores/times.svelte' + import TimeDialog from '$lib/components/TimeDialog.svelte' + import { formatDateRange } from '$lib/time' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -44,6 +46,9 @@ return `/dashboard/events/${event?.id}/runs/${runId}` } + // Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen. + let timeDialog = $state(false) + // Fahrer-Dialog let riderDialog = $state(false) let ridingEdit = $state(null) @@ -137,9 +142,12 @@

{event.name || 'Unbenannt'}

- - {event.status === 'active' ? 'Aktiv' : event.status === 'finished' ? 'Abgeschlossen' : 'Entwurf'} - + {#if event.starts} + + + {formatDateRange(event.starts, event.ends)} + + {/if}
{#if event.location}

@@ -151,6 +159,14 @@

{event.description}

{/if}
+ +
@@ -166,10 +182,12 @@

Klick auf einen Fahrer zeigt seine Runs

- + {#if riders.canManage} + + {/if}
@@ -178,11 +196,17 @@

Noch keine Fahrer

-

Füge den ersten Fahrer hinzu.

- + {#if riders.canManage} +

Füge den ersten Fahrer hinzu.

+ + {:else} +

+ Fahrer legt die Teamleitung an. +

+ {/if}
{:else} @@ -215,12 +239,14 @@ {done} von {eventRuns.length} Runs e.stopPropagation()}> - - + {#if riders.canManage} + + + {/if} {/each} @@ -370,3 +396,5 @@ + + diff --git a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte index a3871da..954d934 100644 --- a/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte @@ -12,12 +12,14 @@ import { getTimeContext } from '$lib/stores/times.svelte' import { ticker } from '$lib/stores/ticker.svelte' import { TIME_STATUS_LABEL, formatDuration } from '$lib/time' + import TimeDialog from '$lib/components/TimeDialog.svelte' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import * as Table from '@/components/ui/table' import { ArrowLeft, ChevronRight, Timer, Users } from 'lucide-svelte' + const events = getEventContext() const runs = getRunContext() const riders = getRiderContext() @@ -59,6 +61,15 @@ }) }) + // Start und Stopp laufen ausschließlich über den Zeit-Dialog. + let timeDialog = $state(false) + let dialogRun = $state(null) + + function openTimeDialog(runId: string | null = null) { + dialogRun = runId + timeDialog = true + } + const stats = $derived.by(() => { const finished = rows.filter((r) => r.best) const total = finished.reduce((sum, r) => sum + (r.best?.duration ?? 0), 0) @@ -88,20 +99,27 @@
{:else if event && rider}
-
- - - {event.name || 'Event'} - -
- {rider.number || '—'} -

- {fullName(rider) || 'Ohne Name'} -

+
+
+ + + {event.name || 'Event'} + +
+ {rider.number || '—'} +

+ {fullName(rider) || 'Ohne Name'} +

+
+ +
@@ -215,3 +233,10 @@
{/if} + + diff --git a/frontend/src/routes/dashboard/riders/+page.svelte b/frontend/src/routes/dashboard/riders/+page.svelte new file mode 100644 index 0000000..bdee0c5 --- /dev/null +++ b/frontend/src/routes/dashboard/riders/+page.svelte @@ -0,0 +1,143 @@ + + +Fahrer + +
+
+
+

Fahrer

+

+ {riders.scoped.length} im Team {teams.active?.name ?? ''} +

+
+ {#if riders.canManage} + + {/if} +
+ +
+ + +
+ + {#if riders.scoped.length === 0} + + + +

Noch keine Fahrer

+

+ {#if riders.canManage} + Leg sie in den Einstellungen an. + {:else} + Die Teamleitung legt Fahrer in den Einstellungen an. + {/if} +

+
+
+ {:else if roster.length === 0} + + Keine Treffer. + + {:else} + + + + + + # + Name + Event + Zeiten + Konto + + + + {#each roster as r (r.id)} + {@const path = riderPath(r)} + + + {r.number || '—'} + + + {#if path} + {fullName(r) || '— ohne Name —'} + {:else} + {fullName(r) || '— ohne Name —'} + {/if} + + + {eventName(r.event) ?? '—'} + + + {times.getByRider(r.id).length} + + + {#if r.user} + + + {teams.userLabel(r.user)} + + {:else} + + {/if} + + + {/each} + + + + + {/if} +
diff --git a/frontend/src/routes/dashboard/settings/+layout.svelte b/frontend/src/routes/dashboard/settings/+layout.svelte new file mode 100644 index 0000000..612abb8 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/+layout.svelte @@ -0,0 +1,57 @@ + + +Einstellungen + +
+

Einstellungen

+ + + + {@render children?.()} +
diff --git a/frontend/src/routes/dashboard/settings/+page.svelte b/frontend/src/routes/dashboard/settings/+page.svelte new file mode 100644 index 0000000..26bb038 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/+page.svelte @@ -0,0 +1,191 @@ + + +
+ + + + + Konto + + + +
+ {#if avatarUrl} + + {:else} +
+ +
+ {/if} +
+ + (avatarFile = e.currentTarget.files?.[0] ?? null)} + /> +
+
+ +
+ + +
+ +
+ + +

+ Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die + Team-Administration über PocketBase. +

+
+ + {#if profileError} +

{profileError}

+ {:else if profileMessage} +

{profileMessage}

+ {/if} + + +
+
+ + + + + + Passwort ändern + + + +
+ + +
+
+ + +
+
+ + +
+ + {#if passwordError} +

{passwordError}

+ {:else if passwordMessage} +

{passwordMessage}

+ {/if} + + +
+
+
diff --git a/frontend/src/routes/dashboard/settings/flags/+page.svelte b/frontend/src/routes/dashboard/settings/flags/+page.svelte index 5fb1727..3b607de 100644 --- a/frontend/src/routes/dashboard/settings/flags/+page.svelte +++ b/frontend/src/routes/dashboard/settings/flags/+page.svelte @@ -7,6 +7,8 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import * as Dialog from '@/components/ui/dialog' + import FlagIcon from '$lib/components/FlagIcon.svelte' + import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons' import { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte' import type { TrailFlagsResponse } from '$lib/types' @@ -104,10 +106,9 @@ } -
+
-

Flag-Typen

Wofür sich Marker auf einem Trail setzen lassen.

@@ -149,7 +150,7 @@ {#each flags.scoped as flag (flag.id)} - + {flag.label} {flag.severity} @@ -203,10 +204,38 @@
- +
+ + + + +

- Name eines Icons von lucide.dev, in Schreibweise wie „TreePine". + Jeder Name von lucide.dev + ist möglich — geschrieben wie „TreePine" oder „tree-pine". + {#if form.icon.trim() && !iconExists(form.icon)} + Diesen Namen gibt es dort nicht. + {/if}

+ +

Vorschläge:

+
+ {#each FLAG_ICON_SUGGESTIONS as name (name)} + + {/each} +
{#if error}

{error}

diff --git a/frontend/src/routes/dashboard/settings/riders/+page.svelte b/frontend/src/routes/dashboard/settings/riders/+page.svelte new file mode 100644 index 0000000..cdd020d --- /dev/null +++ b/frontend/src/routes/dashboard/settings/riders/+page.svelte @@ -0,0 +1,385 @@ + + +{#if !canManage} + + + Fahrer verwaltet, wer das Team führt. Die Fahrer deines Teams siehst du + unter Fahrer. + + +{:else} +
+
+

+ Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst + anzumelden. +

+ +
+ + {#if roster.length === 0} + + + Noch keine Fahrer im Team. + + + {:else} + + + + + + # + Name + Event + Konto + Aktionen + + + + {#each roster as r (r.id)} + + + {r.number || '—'} + + + {fullName(r) || '— ohne Name —'} + + + {eventName(r.event) ?? '—'} + + + {#if r.user} + + + + kann sich einloggen + + + {teams.userLabel(r.user)} + + + {:else} + + {/if} + + + {#if r.user} + + {:else} + + {/if} + + + + + {/each} + + + + + {/if} +
+{/if} + + + + + + {editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'} + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + + + {eventName(form.event) ?? 'Keinem Event zugeordnet'} + + + Keinem Event zugeordnet + {#each events.scoped as e (e.id)} + {e.name || e.description} + {/each} + + +

+ Zeiten hängen am Event. Ohne Zuordnung steht der Fahrer nur im + Team-Verzeichnis. +

+
+ {#if riderError} +

{riderError}

+ {/if} +
+ + + + +
+
+ + + + + + Konto für {loginRider ? fullName(loginRider) : 'Fahrer'} + + Mit einem Konto kann sich die Person selbst anmelden. + + + +
+ + +
+ +
+ {#if loginMode === 'link'} +
+ + + + {linkUserId ? teams.userLabel(linkUserId) : 'Mitglied wählen…'} + + + {#each linkCandidates as id (id)} + {teams.userLabel(id)} + {/each} + + + {#if linkCandidates.length === 0} +

+ Alle Teammitglieder sind bereits einem Fahrer zugeordnet. +

+ {/if} +
+ {:else} +
+ + +
+
+ + +

+ Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt + sich unter „Profil" ändern. +

+
+ {/if} + + {#if loginError} +

{loginError}

+ {/if} +
+ + + + {#if loginMode === 'link'} + + {:else} + + {/if} + +
+
diff --git a/frontend/src/routes/dashboard/settings/team/+page.svelte b/frontend/src/routes/dashboard/settings/team/+page.svelte new file mode 100644 index 0000000..cbb24a9 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/team/+page.svelte @@ -0,0 +1,329 @@ + + +
+
+

+ Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und + Trails du siehst. +

+ +
+ + {#if teams.records.length === 0} + + + +

Noch in keinem Team

+

Erstelle dein erstes Team.

+ +
+
+ {:else} + {#each teams.records as t (t.id)} + {@const iAmOwner = t.owner === me} + {@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')} + + +
+
+ + {t.name} + {#if t.id === teams.activeId} + Aktiv + {/if} + + + {(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'} + +
+
+ {#if t.id !== teams.activeId} + + {/if} + {#if iAmAdmin} + + + {/if} + {#if !iAmOwner} + + {:else} + + {/if} +
+
+
+ +
    + {#each (t.users ?? []) as userId (userId)} + {@const isOwner = userId === t.owner} + {@const isAdm = (t.admins ?? []).includes(userId)} +
  • +
    + {teams.userLabel(userId)} + {#if isOwner} + + + Owner + + {:else if isAdm} + + + Admin + + {/if} + {#if userId === me} + Du + {/if} +
    + {#if iAmOwner && !isOwner} +
    + {#if isAdm} + + {:else} + + {/if} + +
    + {/if} +
  • + {/each} +
+
+
+ {/each} + {/if} +
+ + + + + + Neues Team + Du wirst automatisch Owner. + +
+
+ + +
+ {#if createError} +

{createError}

+ {/if} +
+ + + + +
+
+ + + + + + Team umbenennen + +
+
+ + +
+ {#if renameError} +

{renameError}

+ {/if} +
+ + + + +
+
+ + + + + + Mitglied anlegen + + Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf. + + +
+
+ + +
+
+ + +
+
+ + +

+ Gib es der Person weiter; sie kann es unter „Profil" ändern. +

+
+ {#if memberError} +

{memberError}

+ {/if} +
+ + + + +
+
diff --git a/frontend/src/routes/dashboard/teams/+page.svelte b/frontend/src/routes/dashboard/teams/+page.svelte deleted file mode 100644 index 26a90bc..0000000 --- a/frontend/src/routes/dashboard/teams/+page.svelte +++ /dev/null @@ -1,341 +0,0 @@ - - -Teams – Zeitnahme - -
-
-
-

Teams

-

Deine Teams und Mitglieder verwalten

-
- -
- - {#if teams.records.length === 0} - - - -

Noch in keinem Team

-

Erstelle dein erstes Team.

- -
-
- {:else} -
- {#each teams.records as t (t.id)} - {@const iAmOwner = t.owner === me} - {@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')} - - -
-
- - {t.name} - {#if t.id === teams.activeId} - Aktiv - {/if} - - - {(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'} - -
-
- {#if t.id !== teams.activeId} - - {/if} - {#if iAmAdmin} - - {/if} - {#if !iAmOwner} - - {/if} - {#if iAmOwner} - - {/if} -
-
-
- -
    - {#each (t.users ?? []) as userId} - {@const isOwner = userId === t.owner} - {@const isAdm = isAdmin(t, userId)} -
  • -
    - {userLabel(userId)} - {#if isOwner} - - - Owner - - {:else if isAdm} - - - Admin - - {/if} - {#if userId === me} - Du - {/if} -
    - {#if iAmOwner && !isOwner} -
    - {#if isAdm} - - {:else} - - {/if} - -
    - {/if} -
  • - {/each} -
-
-
- {/each} -
- {/if} -
- - - - - - Neues Team - Du wirst automatisch Owner. - -
{ - e.preventDefault() - doCreate() - }} - class="space-y-4" - > -
- - -
- - - - -
-
-
- - - - - - Mitglied hinzufügen - - Suche einen registrierten User über seine E-Mail-Adresse. - - -
{ - e.preventDefault() - doSearch() - }} - class="space-y-4" - > -
- -
- - -
-
- - {#if inviteError} -
- {inviteError} -
- {/if} - - {#if inviteFound} -
-

{inviteFound.name || inviteFound.email}

- {#if inviteFound.email} -

{inviteFound.email}

- {/if} -
- {/if} - - - - - -
-
-