Compare commits

..

No commits in common. "0f4b532773814c1e32b34953d9cf9bb76f108460" and "83a1693ef14cdc2ce94ac6e86e0cd33a44b84319" have entirely different histories.

48 changed files with 1757 additions and 3447 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
stammtisch-hersbruck.de is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, stages, riders, times and teams for a motorsport/cycling event organization. stammtisch-hersbruck.de is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, runs, riders, times and teams for a motorsport/cycling event organization.
The repository is a monorepo: the SvelteKit app lives in `frontend/`, the PocketBase instance in `backend/`. The repository is a monorepo: the SvelteKit app lives in `frontend/`, the PocketBase instance in `backend/`.
@ -60,20 +60,7 @@ docker compose down
Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe
`backend/README.md`. `backend/README.md`.
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts` - **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
- **Collections**: users, teams, team_invites, events, stages, riders, times, - **Collections**: users, teams, events, runs, riders, times
trails, trail_versions, trail_flags, trail_markers, trail_comments,
event_participants, event_series
- **Regeln**: Jede Zugriffsregel, die `@request.auth.id` erwähnt, beginnt mit
`@request.auth.id != "" && (…)`. Ohne diesen Vorspann ist eine *leere*
Relation gleich dem leeren `@request.auth.id` einer anonymen Anfrage — ein
Team ohne Admins stünde damit offen im Netz. Siehe
`backend/pb_migrations/1754501700_rules_require_login.js`.
- **Hooks**: `backend/pb_hooks/` enthält serverseitiges JS für das, was keine
Collection-Regel abbilden kann — derzeit die öffentlichen Routen der
Einladungslinks. Jeder Route-Handler läuft in einer eigenen JS-Laufzeit;
gemeinsamer Code gehört in ein Modul und wird per
``require(`${__hooks}/…`)`` im Handler geholt. Details in
`backend/README.md`.
- **Authentication**: Handled through `src/lib/stores/pocketbase.svelte.ts` with the `AuthStore` class - **Authentication**: Handled through `src/lib/stores/pocketbase.svelte.ts` with the `AuthStore` class
- **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs - **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs
@ -149,18 +136,7 @@ tippy.js, which the action initializes, updates and destroys.
### File Structure Notes ### File Structure Notes
- Routes are in `src/routes/` following SvelteKit conventions. **Routennamen - Routes are in `src/routes/` following SvelteKit conventions
immer auf Englisch** (`/invite/[token]`, nicht `/einladung/[token]`) —
Oberflächentexte sind deutsch, Pfade nicht.
- Der Menüpunkt **Team** (`/dashboard/team`) führt Team und Kader an einer
Stelle: Kennzahlen, Teamdaten, Einladungslinks und die Fahrerliste samt der
Verwaltungsknöpfe der Teamleitung. Unter **Einstellungen** steht nur noch,
was einen selbst betrifft (Profil, Flag-Typen).
- **Ein Fahrer, ein Name, eine Richtung**: Ein Konto entsteht immer am Fahrer
(`riders.createLogin`), es gibt kein Verknüpfen bestehender Konten und kein
Lösen. Angezeigt wird ausschließlich der Fahrername — der Kontoname wäre
eine zweite Wahrheit, die niemand geradeziehen könnte, weil
`users.updateRule` nur den Kontoinhaber selbst ändern lässt.
- Reusable components in `src/lib/components/` - Reusable components in `src/lib/components/`
- Stores use `.svelte.ts` extension for Svelte 5 runes - Stores use `.svelte.ts` extension for Svelte 5 runes
- Static assets in `static/` - Static assets in `static/`

View file

@ -1,31 +0,0 @@
Die "Wanderer"-Schiene
Shredder: Ziemlich actiongeladen, 100% MTB-Jargon und hat genau wie "Wanderer" dieses aktive, ausführende Element.
Kurbler: Die direktere, etwas rotzigere deutsche Alternative. Hat einen leichten Retro-Charme.
Dropper: Angelehnt an die Dropper-Post (absenkbare Sattelstütze) oder das "Reindroppen" in den Trail.
Fokus auf Trail & Community
TrailTribe: Betont den Vereins- und Gruppenaspekt hervorragend.
DirtBase: Perfekt für die Traildatenbank kurz, prägnant und technisch sauber.
FlowHub: Verbindet das ultimative MTB-Gefühl ("Flow") mit dem modernen Plattform-Gedanken für Eventveranstalter.
Fokus auf Zeitnahme & Action
SendSync: "Send it!" gepaart mit der schnellen Datensynchronisation einer Webapp.
PacePeak: Klingt sportlich, kompetitiv und extrem passend für Rennveranstalter und Bestzeitenjäger.
TrailTicker: Etwas verspielter, bringt aber das Thema Zeitmessung und Live-Updates sofort auf den Punkt.
Kurz & Knackig (Startup-Vibe)
Berm: Englisch für die Anliegerkurve. Kurz, international und unter Mountainbikern ein fester Begriff.
Roost: Der aufgeworfene Dreck in der Kurve. Hat Energie und ist sehr spezifisch für den Offroad-Sport.
Stollen: Ein sehr deutsches, geerdetes Wort (Stollenreifen), das MTB-Flair mit technischer Präzision verbindet.

118
TODO.md
View file

@ -0,0 +1,118 @@
# Offene Punkte
Stand: 2026-09-02, abgeglichen mit der produktiven Instanz. Die fachlichen
Entscheidungen sind gefallen und gebaut; was hier steht, passiert außerhalb des
Repos oder wartet auf den nächsten Deploy.
## Vor dem nächsten Deploy
### `PUBLIC_CARTO_API_KEY` in Coolify setzen
Die Kartenkacheln kommen von CARTO. Der Schlüssel muss den Browser erreichen,
deshalb das `PUBLIC_`-Präfix — ohne das reicht SvelteKit ihn nicht weiter.
Gelesen wird er über `$env/dynamic/public`, das heißt: Die Variable muss in der
**Laufzeitumgebung** des `node build`-Prozesses stehen, nicht nur zur Buildzeit.
In Coolify als normale Umgebungsvariable eintragen genügt. Fehlt sie, laufen die
Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar.
### Migrationen: sechs gelaufen, fünf offen
Abgeglichen mit `api.stammtisch-hersbruck.de` (nur gelesen): Der Backend-Deploy
hat alles bis `1754500900` mitgenommen, ab `1754501000` steht es noch aus.
| Datei | Was sie tut | Stand |
|---|---|---|
| `1754500500_riders_user_and_admin_rules` | `riders.user`, Fahrerpflege nur Teamleitung, `users.viewRule`/`createRule` | gelaufen |
| `1754500600_events_dates_instead_of_status` | `events.status` raus, `starts`/`ends` rein | gelaufen |
| `1754500700_trail_status_meta` | `trails.status_changed`, `status_by` | gelaufen |
| `1754500800_trail_markers_custom` | `trail_markers.label`/`icon`, `flag` optional | gelaufen |
| `1754500900_users_superadmin` | `users.superadmin` samt Schutz vor Selbstbeförderung | gelaufen |
| `1754501000_teams_logo` | `teams.logo` | **offen** |
| `1754501100_trails_status_report` | Mitglieder dürfen den Trail-Zustand melden | **offen** |
| `1754501200_users_list_for_invites` | `users.listRule` gelockert (Kontosuche) | **offen** |
| `1754501300_event_participants` | neue Collection, **verschiebt Daten**, entfernt `riders.event`/`number` | **offen** |
| `1754501400_events_timing_participation` | `events.timing`/`participation` | **offen** |
| `1754501500_event_series` | `event_series`, `events.series` | **offen** |
Bis zum nächsten Deploy laufen deshalb ins Leere: Team-Logo, Zustandsmeldung
durch Mitglieder, Kontosuche per E-Mail, Teilnehmerlisten, die Event-Schalter
und die Serien. Die Oberfläche dafür ist da und wird beim Speichern eine
Fehlermeldung von PocketBase zeigen.
**Zwei Dinge sind beim Nachziehen nicht zurückzunehmen:**
1. `1754501300` legt für jeden Fahrer mit Event einen Teilnehmer an und entfernt
danach `riders.event` und `riders.number`. Eine Startnummer an einem Fahrer
**ohne** Event hat danach keinen Platz mehr und geht verloren — sie war
ohnehin nirgends sichtbar, weil alle Listen nach Event filterten.
2. Bestehende Events werden auf `timing = true` und `participation =
geschlossen` gesetzt. Genau so wurden sie bisher benutzt; wer einen
Stammtisch daraus machen will, schaltet die Zeitnahme im Dialog ab.
Ebenfalls bewusst so: Nach `1754501200` kann jeder Eingeloggte Konten
auflisten, damit sich bestehende Konten per E-Mail ins Team holen lassen.
E-Mail-Adressen bleiben über `emailVisibility` verborgen.
### Typen einmal neu erzeugen
`frontend/src/lib/types.d.ts` wurde bei allen Schema-Änderungen von Hand
gepflegt. Grund: `pocketbase-typegen` braucht native sqlite3-Bindings, die in
der Arbeitsumgebung nicht gebaut werden durften.
```bash
cd frontend && npm run generate-pocketbase-types
```
Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis
des Generators.
### Regeln einmal durchspielen
Die Zugriffsregeln der neuen Collections konnten nirgends laufen — weder Docker
noch eine lokale PocketBase-Instanz standen zur Verfügung. Sie sind nach dem
Muster der vorhandenen Collections geschrieben, aber ungetestet. Nach dem Deploy
einmal ausprobieren:
- Ein einfaches Teammitglied trägt sich bei einem **offenen** Event selbst ein.
- Dasselbe bei einem Event **mit Bestätigung** — es muss als „Angefragt"
landen, nicht als „Dabei".
- Bei einem **geschlossenen** Event darf es gar nicht gehen.
- Ein Mitglied meldet einen Trail-Zustand (muss gehen) und versucht, den Namen
des Trails zu ändern (darf nicht gehen).
Der Selbsteintrag setzt außerdem voraus, dass das Konto der Person mit einem
Fahrer verknüpft ist (Einstellungen → Fahrer & Konten). Ohne Verknüpfung
erscheint der Knopf gar nicht erst.
### Logo des Stammtischs zuordnen
`teams.logo` gibt es ab `1754501000`. Solange nichts hinterlegt ist, zeigt die
App weiterhin das Wappen aus `static/` — für den Stammtisch Hersbruck sieht es
also unverändert aus. Wer es als Datei am Team haben will, lädt es nach dem
Deploy unter Einstellungen → Team → Logo hoch.
## Kleinigkeiten
- Wer Regeln direkt im PocketBase-Admin ändert, hinterlässt keine Spur im Repo.
Die Migrationen bleiben die Quelle: Sie setzen dieselben Werte noch einmal und
überschreiben eine abweichende Handänderung stillschweigend.
- Die Rolle **Superadmin** ist gesetzt, gibt in der App aber noch keine
besonderen Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die
`updateRule` der users-Collection schließt `superadmin` vom Selbstsetzen aus.
Sobald klar ist, was sie dürfen soll, kommen die Regeln dazu.
- Flag-Typen sind **teamspezifisch**. Ein neues Team bekommt beim Anlegen den
hartkodierten Standardsatz aus `trailFlags.svelte.ts` und kann ihn frei
ändern.
- „Jedes Teammitglied ist ein Fahrer" hält die Oberfläche ein, das Backend
erzwingt es nicht: Ein Konto kann im Team sein, ohne an einem Fahrer zu
hängen. Solche Fälle stehen unter „Fahrer & Konten" mit dem Vermerk „kein
Fahrer" und lassen sich per Knopf übernehmen.
- „Als Fahrer übernehmen" rät den Namen aus dem Kontonamen und setzt ihn als
Vornamen. „Max Mustermann" auf zwei Felder aufzuteilen wäre geraten und läge
bei Doppelnamen daneben; der Name lässt sich danach korrigieren. Wenn das zu
grob ist, müsste der Knopf stattdessen nach Vor- und Nachname fragen.
- Serien erzeugen bis zu zwölf Termine je Klick und überspringen, was schon
angelegt ist. Ein Termin, der ausfällt, wird als Event gelöscht oder
verschoben — die Serie bleibt davon unberührt.
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.

View file

@ -21,7 +21,7 @@ Damit das Frontend gegen diese Instanz läuft, in `../frontend/.env`
Das Schema liegt als versionierte Migration in `pb_migrations/` und wird beim Das Schema liegt als versionierte Migration in `pb_migrations/` und wird beim
Start automatisch angewendet. Es enthält die sechs fachlichen Collections Start automatisch angewendet. Es enthält die sechs fachlichen Collections
`users`, `teams`, `events`, `stages`, `riders`, `times`. `users`, `teams`, `events`, `runs`, `riders`, `times`.
`1754400000_init_schema.js` ist ein Snapshot der produktiven Instanz. Er wurde `1754400000_init_schema.js` ist ein Snapshot der produktiven Instanz. Er wurde
per `GET /api/collections` abgezogen und ruft `importCollections` mit per `GET /api/collections` abgezogen und ruft `importCollections` mit
@ -50,85 +50,6 @@ Ohne diesen Schritt ist die Änderung beim nächsten `--build` verloren.
Ein frisch gestarteter Container hat damit dasselbe Schema wie die produktive Ein frisch gestarteter Container hat damit dasselbe Schema wie die produktive
Instanz — aber **keine** Daten. Instanz — aber **keine** Daten.
### Regeln: der Login-Vorspann ist Pflicht
Jede Regel, die `@request.auth.id` erwähnt, beginnt mit
`@request.auth.id != "" && (…)`. Das ist kein Stilmittel, sondern schließt ein
Loch:
**Eine leere Relation ist in PocketBase gleich dem leeren `@request.auth.id`
einer anonymen Anfrage.** `team.admins.id ?= @request.auth.id` ist damit für
jedes Team **ohne Admins** wahr — und ohne Admins ist jedes frisch angelegte
Team. Dasselbe gilt für jede optionale Relation: `created_by`, `stewards`,
`rider.user`.
Nachgewiesen an einer lokalen Instanz mit dem Schema aus diesem Repo: Ein
`POST` auf `/api/collections/riders/records` **ohne jeden Token** legte den
Fahrer an. Betroffen waren nach demselben Muster `teams` (update), `riders`,
`trails`, `trail_versions`, `trail_flags`, `trail_markers`, `trail_comments`,
`event_series` und `event_participants`.
`1754501700_rules_require_login.js` setzt den Vorspann vor jede betroffene
Regel. Er kann nichts erlauben, was vorher verboten war — er nimmt nur die
anonyme Anfrage heraus, die keine dieser Regeln je erfüllen sollte. Gelesen
werden die Regeln aus der Datenbank, eine Handänderung aus dem Admin-UI wird
also abgesichert und nicht überschrieben.
Nach dem Deploy einmal gegenprüfen — das muss scheitern:
curl -X POST https://api.stammtisch-hersbruck.de/api/collections/riders/records \
-H 'Content-Type: application/json' \
-d '{"team":"<TEAM_ID>","name":"Test"}'
Wer eine neue Regel schreibt, setzt den Vorspann mit. Sonst reißt dasselbe
Loch an der nächsten Stelle wieder auf.
### `1754501300` scheitert auf einer frischen Datenbank
Bekannt und **nicht behoben**: Bei `docker compose up --build` mit leerem
`pb_data` legt der Snapshot `1754400000` die Collection `riders` bereits ohne
das Feld `event` an. `1754501300_event_participants.js` sucht danach mit
`findRecordsByFilter('riders', 'event != "")` und bricht mit
`unknown field "event"` ab — PocketBase startet dann gar nicht.
Auf der produktiven Instanz tritt das nicht auf, dort gibt es das Feld noch.
Angefasst wurde die Migration deshalb nicht; wer lokal von Null startet,
braucht diese eine Zeile:
const existing = riders.fields.getByName('event')
? app.findRecordsByFilter('riders', 'event != ""', '', 0, 0)
: []
## Hooks (`pb_hooks/`)
Serverseitiges JavaScript, das PocketBase beim Start lädt (`--hooksDir`). Wie
`pb_migrations/` kommt das Verzeichnis per `COPY` ins Image — eine Änderung
wirkt also erst nach `docker compose up -d --build`.
Bisher liegt dort ein Anliegen: die öffentlichen Routen der Einladungslinks.
| Datei | Zweck |
|---|---|
| `team_invites.pb.js` | `GET /api/team-invites/{token}`, `POST …/accept`, `POST …/join` |
| `team_invites_lib.js` | Gemeinsame Helfer der drei Routen |
Warum überhaupt ein Hook? Wer über einen Link beitritt, ist noch nicht
angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel
einzeln erlauben darf: ein Konto anlegen, sich in `teams.users` eintragen und
einen Fahrer anlegen. Ein Hook arbeitet an den Regeln vorbei und kann genau
diese Kombination erlauben — gegen einen gültigen Token und sonst nicht.
**Zwei Fallstricke, an denen die erste Fassung scheiterte:**
1. Jeder Route-Handler läuft in einer **eigenen** JS-Laufzeit. Funktionen, die
in derselben Datei neben `routerAdd` stehen, sind im Handler *nicht*
sichtbar (`ReferenceError`). Gemeinsamer Code gehört deshalb in ein Modul,
das der Handler sich per ``require(`${__hooks}/…`)`` selbst holt.
2. Was `record.get()` bei einer Mehrfach-Relation liefert, ist ein Go-Slice
und kein JS-Array — `indexOf` und Konsorten sind darauf nicht verlässlich.
Anhängen geht über den Modifier `record.set('users+', id)`, Prüfen über
einen Filter.
## Datenpersistenz ## Datenpersistenz
Sämtliche Laufzeitdaten liegen in der SQLite-DB unter `/pb/pb_data`: Datensätze, Sämtliche Laufzeitdaten liegen in der SQLite-DB unter `/pb/pb_data`: Datensätze,

View file

@ -1,145 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Die öffentliche Seite der Einladungslinks.
//
// Warum überhaupt eigene Routen? Wer einem Team über einen Link beitritt, ist
// noch nicht eingeloggt und muss dabei drei Dinge tun, die keine
// Collection-Regel erlauben darf:
//
// 1. ein Konto anlegen — `users.createRule` verlangt einen Login, sonst
// wäre es eine offene Registrierung für jedermann;
// 2. sich in `teams.users` eintragen — das darf sonst nur die Teamleitung;
// 3. einen Fahrer im Kader anlegen — dito.
//
// Ein Hook läuft an den Regeln vorbei und kann deshalb genau diese drei
// Schritte erlauben, aber nur zusammen und nur gegen einen gültigen Token.
// Die Collection `team_invites` selbst bleibt für Nicht-Mitglieder unsichtbar.
//
// Routen:
// GET /api/team-invites/{token} — öffentlich, zeigt das Team
// POST /api/team-invites/{token}/accept — öffentlich, Konto + Beitritt
// POST /api/team-invites/{token}/join — eingeloggt, nur Beitritt
//
// Jeder Handler holt sich seine Helfer selbst per require(): PocketBase führt
// Route-Handler in einer eigenen JS-Laufzeit aus, der Dateikopf hier ist dort
// nicht sichtbar.
// --- Wofür gilt der Link? ---------------------------------------------------
//
// Öffentlich, weil die Seite den Teamnamen zeigen soll, bevor jemand seine
// Daten einträgt. Mehr als Name und Logo gibt sie nicht preis; die Einladung
// selbst — Zähler, Ablauf, wer sie erzeugt hat — bleibt drin.
routerAdd('GET', '/api/team-invites/{token}', (e) => {
const lib = require(`${__hooks}/team_invites_lib.js`)
const invite = lib.findValidInvite(e.app, e.request.pathValue('token'))
if (!invite) {
throw new NotFoundError('Diese Einladung gibt es nicht mehr.')
}
return e.json(200, { team: lib.teamPayload(e.app, invite) })
})
// --- Konto anlegen und beitreten -------------------------------------------
routerAdd('POST', '/api/team-invites/{token}/accept', (e) => {
const lib = require(`${__hooks}/team_invites_lib.js`)
const data = new DynamicModel({
email: '',
password: '',
passwordConfirm: '',
name: '',
})
e.bindBody(data)
const email = (data.email || '').trim()
const name = (data.name || '').trim()
if (!name) {
throw new BadRequestError('Bitte einen Namen angeben.')
}
if (email.indexOf('@') < 1) {
throw new BadRequestError('Bitte eine gültige E-Mail-Adresse angeben.')
}
if ((data.password || '').length < 8) {
throw new BadRequestError('Das Passwort braucht mindestens 8 Zeichen.')
}
if (data.password !== data.passwordConfirm) {
throw new BadRequestError('Die beiden Passwörter stimmen nicht überein.')
}
let result
e.app.runInTransaction((txApp) => {
// Innerhalb der Transaktion noch einmal nachsehen: Zwischen Anzeige
// der Seite und dem Absenden kann die Einladung abgelaufen,
// aufgebraucht oder gelöscht worden sein.
const invite = lib.findValidInvite(txApp, e.request.pathValue('token'))
if (!invite) {
throw new BadRequestError('Diese Einladung gilt nicht mehr.')
}
// Ohne die Vorabprüfung meldete PocketBase nur „Value must be unique".
// Der Hinweis auf den zweiten Weg ist hier mehr wert als die
// Fehlermeldung.
let existing = null
try {
existing = txApp.findAuthRecordByEmail('users', email)
} catch (err) {
existing = null
}
if (existing) {
throw new BadRequestError(
'Zu dieser E-Mail-Adresse gibt es schon ein Konto. ' +
'Melde dich damit an und öffne den Link danach erneut.',
)
}
const user = new Record(txApp.findCollectionByNameOrId('users'))
user.setEmail(email)
user.setPassword(data.password)
user.setVerified(false)
user.set('emailVisibility', false)
user.set('name', name)
txApp.save(user)
result = lib.joinTeam(txApp, invite, user, name)
})
// Kein Auth-Token in der Antwort: Die App meldet sich anschließend ganz
// normal mit E-Mail und Passwort an und läuft damit durch dieselbe
// Anmeldung wie jeder andere auch.
return e.json(200, { ok: true, team: result.teamId })
})
// --- Als bereits angemeldeter Nutzer beitreten ------------------------------
routerAdd('POST', '/api/team-invites/{token}/join', (e) => {
const lib = require(`${__hooks}/team_invites_lib.js`)
const data = new DynamicModel({ name: '' })
e.bindBody(data)
const user = e.auth
if (!user) {
throw new UnauthorizedError()
}
let result
e.app.runInTransaction((txApp) => {
const invite = lib.findValidInvite(txApp, e.request.pathValue('token'))
if (!invite) {
throw new BadRequestError('Diese Einladung gilt nicht mehr.')
}
// Fällt der Name weg, stünde der Beitretende ohne Namen im Kader. Der
// Kontoname ist die bessere Näherung als gar nichts.
const name = (data.name || '').trim() || user.getString('name')
result = lib.joinTeam(txApp, invite, user, name)
})
return e.json(200, { ok: true, team: result.teamId })
}, $apis.requireAuth())

View file

@ -1,122 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Gemeinsamer Code der Einladungs-Routen.
//
// Warum eine eigene Datei? PocketBase führt jeden Route-Handler in einer
// eigenen JS-Laufzeit aus. Was in team_invites.pb.js neben routerAdd steht,
// ist im Handler nicht sichtbar — der Handler kennt nur seine eigenen
// Variablen, die Globals und was er sich per require() holt.
const INVITES = 'team_invites'
/**
* Die Einladung zum Token, sofern sie gilt sonst null.
*
* `expires` kommt als "2026-09-30 10:00:00.000Z" zurück, leer heißt: läuft
* nicht ab. `max_uses` 0 heißt: beliebig oft.
*/
function findValidInvite(app, token) {
if (!token) {
return null
}
let invite
try {
invite = app.findFirstRecordByFilter(INVITES, 'token = {:token}', { token: token })
} catch (err) {
return null
}
const expires = invite.getString('expires')
if (expires) {
const ts = Date.parse(expires.replace(' ', 'T'))
if (!isNaN(ts) && ts < Date.now()) {
return null
}
}
const maxUses = invite.getInt('max_uses')
if (maxUses > 0 && invite.getInt('uses') >= maxUses) {
return null
}
return invite
}
/** Team der Einladung samt Logo — genug, damit die Seite es benennen kann. */
function teamPayload(app, invite) {
const team = app.findRecordById('teams', invite.getString('team'))
return {
id: team.id,
collectionId: team.collection().id,
collectionName: 'teams',
name: team.getString('name'),
logo: team.getString('logo'),
}
}
/**
* Trägt einen bestehenden Nutzer ins Team ein und legt ihm einen Fahrer an,
* falls er dort noch keinen hat. Zählt die Einladung hoch.
*
* Erwartet einen txApp beide Aufrufer stecken in einer Transaktion.
*/
function joinTeam(txApp, invite, user, name) {
const team = txApp.findRecordById('teams', invite.getString('team'))
// Mitgliedschaft über einen Filter prüfen und nicht über das Feld: Was
// `get()` bei einer Mehrfach-Relation zurückgibt, ist ein Go-Slice und
// kein JS-Array — `indexOf` darauf ist nicht verlässlich.
let isMember = false
try {
txApp.findFirstRecordByFilter(
'teams',
'id = {:team} && users.id ?= {:user}',
{ team: team.id, user: user.id },
)
isMember = true
} catch (err) {
isMember = false
}
if (!isMember) {
// "+" hängt an, statt die Liste zu ersetzen — sonst flöge bei zwei
// gleichzeitigen Beitritten einer der beiden wieder raus.
team.set('users+', user.id)
txApp.save(team)
}
// Jedes Teammitglied ist ein Fahrer. Hat der Nutzer im Team schon einen,
// bleibt es bei dem — ein zweiter wäre dieselbe Person doppelt im Kader.
let rider = null
try {
rider = txApp.findFirstRecordByFilter(
'riders',
'team = {:team} && user = {:user}',
{ team: team.id, user: user.id },
)
} catch (err) {
rider = null
}
if (!rider) {
rider = new Record(txApp.findCollectionByNameOrId('riders'))
rider.set('team', team.id)
rider.set('name', name)
rider.set('user', user.id)
txApp.save(rider)
}
// Gezählt wird, wer über den Link ins Team gekommen ist — nicht, wie oft
// jemand darauf geklickt hat. Sonst wäre ein zweiter Klick eines schon
// aufgenommenen Mitglieds bei `max_uses = 1` der letzte für alle anderen.
if (!isMember) {
invite.set('uses', invite.getInt('uses') + 1)
txApp.save(invite)
}
return { teamId: team.id, riderId: rider.id, joined: !isMember }
}
module.exports = { findValidInvite, teamPayload, joinTeam }

View file

@ -588,7 +588,7 @@ migrate((app) => {
"createRule": "team.users.id ?= @request.auth.id", "createRule": "team.users.id ?= @request.auth.id",
"updateRule": "team.users.id ?= @request.auth.id", "updateRule": "team.users.id ?= @request.auth.id",
"deleteRule": "team.users.id ?= @request.auth.id", "deleteRule": "team.users.id ?= @request.auth.id",
"name": "stages", "name": "runs",
"type": "base", "type": "base",
"fields": [ "fields": [
{ {
@ -709,7 +709,7 @@ migrate((app) => {
"min": 0, "min": 0,
"name": "name", "name": "name",
"pattern": "", "pattern": "",
"presentable": true, "presentable": false,
"primaryKey": false, "primaryKey": false,
"required": false, "required": false,
"system": false, "system": false,
@ -764,6 +764,36 @@ migrate((app) => {
"system": false, "system": false,
"type": "autodate" "type": "autodate"
}, },
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2208304744",
"max": 0,
"min": 0,
"name": "firstname",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text824489398",
"max": 0,
"min": 0,
"name": "lastname",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{ {
"cascadeDelete": false, "cascadeDelete": false,
"collectionId": "pbc_1568971955", "collectionId": "pbc_1568971955",
@ -916,7 +946,7 @@ migrate((app) => {
"id": "ltfephwe", "id": "ltfephwe",
"maxSelect": 1, "maxSelect": 1,
"minSelect": 0, "minSelect": 0,
"name": "stage", "name": "run",
"presentable": false, "presentable": false,
"required": false, "required": false,
"system": false, "system": false,
@ -2075,138 +2105,6 @@ migrate((app) => {
], ],
"indexes": [], "indexes": [],
"system": false "system": false
},
{
"id": "pbc_team_invites",
"listRule": "@request.auth.id != \"\" && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id)",
"viewRule": "@request.auth.id != \"\" && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id)",
"createRule": "@request.auth.id != \"\" && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id) && @request.body.token:isset = false",
"updateRule": null,
"deleteRule": "@request.auth.id != \"\" && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id)",
"name": "team_invites",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_1568971955",
"help": "",
"hidden": false,
"id": "relation_invite_team",
"maxSelect": 1,
"minSelect": 0,
"name": "team",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{32}",
"help": "",
"hidden": false,
"id": "text_invite_token",
"max": 32,
"min": 32,
"name": "token",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation_invite_created_by",
"maxSelect": 1,
"minSelect": 0,
"name": "created_by",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "date_invite_expires",
"max": "",
"min": "",
"name": "expires",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"help": "",
"hidden": false,
"id": "number_invite_max_uses",
"max": null,
"min": 0,
"name": "max_uses",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number_invite_uses",
"max": null,
"min": 0,
"name": "uses",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "autodate_created",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate_updated",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE UNIQUE INDEX `idx_team_invites_token` ON `team_invites` (`token`)"
],
"system": false
} }
] ]

View file

@ -1,99 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Einladungslinks: Ein Team gibt einen Link heraus, über den sich Fahrer
// selbst ein Konto anlegen und dem Team beitreten.
//
// Bisher ging das nur andersherum — die Teamleitung legte jedem Fahrer von
// Hand ein Konto an und gab das Passwort weiter. Bei zwanzig Leuten in einer
// WhatsApp-Gruppe ist ein Link die kürzere Strecke.
//
// Der Link ist das Geheimnis. Deshalb:
//
// 1) `token` wird von PocketBase erzeugt (autogeneratePattern) und ist
// eindeutig. Ein mitgeschickter Token wird über die createRule abgelehnt,
// sonst könnte sich jemand einen leicht zu ratenden aussuchen.
//
// 2) Lesen, anlegen und löschen darf nur, wer das Team führt. Es gibt
// bewusst keine updateRule: Ein Link ändert sich nicht, er wird gelöscht
// und neu erzeugt. Den Zähler `uses` schreibt der Hook, der an den Regeln
// vorbei arbeitet.
//
// 3) Die öffentliche Seite der Einladung läuft nicht über diese Collection,
// sondern über die Routen in pb_hooks/team_invites.pb.js. Ohne Login ist
// hier nichts sichtbar — auch nicht mit Token.
//
// `expires` leer heißt: läuft nicht ab. `max_uses` 0 heißt: beliebig oft.
// Der Login-Vorspann ist nicht bloß Gürtel und Hosenträger: Ist `admins`
// leer, vergleicht PocketBase eine leere Relation mit dem leeren
// `@request.auth.id` einer anonymen Anfrage — und das ist wahr. Ohne den
// Vorspann stünde jede Einladung eines Teams ohne Admins offen im Netz.
// Siehe 1754501700_rules_require_login.js.
const ADMIN_RULE =
'@request.auth.id != ""' +
' && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id)'
migrate((app) => {
const teams = app.findCollectionByNameOrId('teams')
const users = app.findCollectionByNameOrId('users')
try {
app.findCollectionByNameOrId('team_invites')
return
} catch {
// Noch nicht da — wird gleich angelegt.
}
const invites = new Collection({
id: 'pbc_team_invites',
type: 'base',
name: 'team_invites',
listRule: ADMIN_RULE,
viewRule: ADMIN_RULE,
// Der Token kommt vom Server. `:isset = false` weist einen
// mitgeschickten ab, statt ihn stillschweigend zu übernehmen.
createRule: ADMIN_RULE + ' && @request.body.token:isset = false',
updateRule: null,
deleteRule: ADMIN_RULE,
fields: [
{
id: 'relation_invite_team',
name: 'team',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: teams.id,
cascadeDelete: true,
},
{
id: 'text_invite_token',
name: 'token',
type: 'text',
required: true,
min: 32,
max: 32,
autogeneratePattern: '[a-zA-Z0-9]{32}',
},
{
id: 'relation_invite_created_by',
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
{ id: 'date_invite_expires', name: 'expires', type: 'date' },
{ id: 'number_invite_max_uses', name: 'max_uses', type: 'number', min: 0, onlyInt: true },
{ id: 'number_invite_uses', name: 'uses', type: 'number', min: 0, onlyInt: true },
{ id: 'autodate_created', name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ id: 'autodate_updated', name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
indexes: [
'CREATE UNIQUE INDEX `idx_team_invites_token` ON `team_invites` (`token`)',
],
})
app.save(invites)
}, (app) => {
app.delete(app.findCollectionByNameOrId('team_invites'))
})

View file

@ -1,96 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Jede Regel, die auf `@request.auth.id` zeigt, bekommt einen Login-Vorspann.
//
// ACHTUNG, das ist ein Loch und keine Kosmetik: Eine *leere* Relation ist in
// PocketBase gleich dem *leeren* `@request.auth.id` einer anonymen Anfrage.
// `team.admins.id ?= @request.auth.id` ist damit für jedes Team ohne Admins
// wahr — auch ohne jeden Login. Dasselbe gilt für jede optionale Relation,
// die leer sein kann: `created_by`, `stewards`, `rider.user`.
//
// Nachgewiesen an einer frischen Instanz mit einem Team ohne Admins:
// Ein POST auf /api/collections/riders/records **ganz ohne Token** legte den
// Fahrer an. Betroffen sind nach demselben Muster
//
// teams update (admins leer)
// riders create/update/delete
// trails update/delete, trail_versions create/delete
// trail_flags create/update/delete
// trail_markers update/delete (created_by, stewards)
// trail_comments update/delete (created_by, stewards)
// event_series update/delete (created_by)
// event_participants create/update/delete (event.created_by)
//
// Der Vorspann `@request.auth.id != "" && (…)` kann nichts erlauben, was
// vorher verboten war — er nimmt nur die anonyme Anfrage heraus, die keine
// dieser Regeln je erfüllen sollte. Deshalb wird er stumpf vor jede Regel
// gesetzt, die `@request.auth.id` erwähnt, statt die betroffenen Stellen
// einzeln zu suchen: Was hier nicht auffällt, fällt sonst gar nicht auf.
//
// Gelesen werden die Regeln aus der Datenbank, nicht aus dieser Datei. Eine
// im Admin-UI von Hand geänderte Regel wird also mit ihrem eigenen Wortlaut
// abgesichert und nicht überschrieben.
const GUARD = '@request.auth.id != ""'
const COLLECTIONS = [
'users', 'teams', 'events', 'runs', 'riders', 'times',
'trails', 'trail_versions', 'trail_flags', 'trail_markers', 'trail_comments',
'event_participants', 'event_series', 'team_invites',
]
const RULES = ['listRule', 'viewRule', 'createRule', 'updateRule', 'deleteRule']
migrate((app) => {
for (const name of COLLECTIONS) {
let collection
try {
collection = app.findCollectionByNameOrId(name)
} catch (err) {
continue // Collection gibt es auf dieser Instanz (noch) nicht.
}
let changed = false
for (const key of RULES) {
const rule = collection[key]
// Leere Regel heißt „nur Superuser" und braucht keinen Schutz;
// eine Regel ohne @request.auth.id ist nicht gemeint.
if (!rule || rule.indexOf('@request.auth.id') === -1) continue
if (rule.indexOf(GUARD) === 0) continue
collection[key] = GUARD + ' && (' + rule + ')'
changed = true
}
if (changed) app.save(collection)
}
}, (app) => {
const prefix = GUARD + ' && ('
for (const name of COLLECTIONS) {
// team_invites bringt den Vorspann aus 1754501600 mit; ihn hier
// abzuräumen hieße, ein Loch zu graben, das es vorher nicht gab.
if (name === 'team_invites') continue
let collection
try {
collection = app.findCollectionByNameOrId(name)
} catch (err) {
continue
}
let changed = false
for (const key of RULES) {
const rule = collection[key]
if (!rule || rule.indexOf(prefix) !== 0 || rule.charAt(rule.length - 1) !== ')') continue
collection[key] = rule.slice(prefix.length, -1)
changed = true
}
if (changed) app.save(collection)
}
})

View file

@ -1,87 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Ein Fahrer hat einen Namen, nicht zwei.
//
// Vorname und Nachname getrennt zu führen versprach eine Ordnung, die es hier
// nie gab: sortiert wird über den ganzen Namen, angezeigt wird der ganze Name,
// gesucht wird über beides zugleich. Dafür musste jede Maske zwei Felder
// anbieten und jede Anzeige sie wieder zusammensetzen — und wer schlicht
// „Schorsch" heißt, stand vor der Frage, welches der beiden Felder das ist.
//
// Das Feld `name` gibt es an riders längst; es lag nur brach, weil die
// Anwendung firstname und lastname benutzte. Es bleibt deshalb, wie es ist,
// und bekommt nur den Inhalt der beiden anderen — kein neues Feld, keine
// zweite Spalte, nichts, was auf bestehenden Instanzen erst entstehen müsste.
//
// Beim Zusammenlegen geht nichts verloren: firstname und lastname werden mit
// einem Leerzeichen verbunden. Zurück geht es nur ungenau — die Umkehrung
// trennt am ersten Leerzeichen und rät damit bei jedem Doppelvornamen falsch.
// Das ist der Preis dafür, dass die Trennung überhaupt verschwindet.
migrate((app) => {
const riders = app.findCollectionByNameOrId('riders')
// Erst zusammenführen, dann die alten Felder abräumen — andersherum wären
// die Daten fort, bevor sie jemand liest. Auf einer frischen Datenbank hat
// der Snapshot die beiden Felder gar nicht erst.
if (riders.fields.getByName('firstname') || riders.fields.getByName('lastname')) {
for (const rider of app.findRecordsByFilter('riders', 'id != ""', '', 0, 0)) {
const merged = [rider.get('firstname'), rider.get('lastname')]
.map((part) => (part || '').trim())
.filter(Boolean)
.join(' ')
// Ein bereits gefülltes `name` gewinnt: Es wäre von Hand gesetzt
// worden und wüsste mehr als die beiden Bruchstücke.
if (merged && !(rider.get('name') || '').trim()) {
rider.set('name', merged)
app.save(rider)
}
}
}
riders.fields.removeByName('firstname')
riders.fields.removeByName('lastname')
// Im Admin-UI steht ein Fahrer jetzt mit Namen statt mit seiner ID da.
const name = riders.fields.getByName('name')
if (name) name.presentable = true
app.save(riders)
}, (app) => {
const riders = app.findCollectionByNameOrId('riders')
if (!riders.fields.getByName('firstname')) {
riders.fields.add(new Field({
id: 'text2208304744',
name: 'firstname',
type: 'text',
presentable: true,
}))
}
if (!riders.fields.getByName('lastname')) {
riders.fields.add(new Field({
id: 'text824489398',
name: 'lastname',
type: 'text',
presentable: true,
}))
}
const name = riders.fields.getByName('name')
if (name) name.presentable = false
app.save(riders)
// Am ersten Leerzeichen trennen: davor der Vorname, danach der Rest. Aus
// „Anna Maria Huber" wird so „Anna" und „Maria Huber".
for (const rider of app.findRecordsByFilter('riders', 'id != ""', '', 0, 0)) {
const full = (rider.get('name') || '').trim()
if (!full) continue
const cut = full.indexOf(' ')
rider.set('firstname', cut === -1 ? full : full.slice(0, cut))
rider.set('lastname', cut === -1 ? '' : full.slice(cut + 1).trim())
app.save(rider)
}
})

View file

@ -1,81 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Jedes Teammitglied bekommt einen Fahrer.
//
// Bis 1754501800 löschte die Anwendung einen Fahrer, ohne sein Konto aus
// `teams.users` zu nehmen. Zurück blieb ein Konto ohne Fahrer — im Kader eine
// Zeile ohne Namen, aus der kein Weg zurückführte: Ein bestehendes Konto
// nachträglich an einen Fahrer zu hängen gibt es bewusst nicht, weil dieselbe
// Person sonst zweimal in der Liste stehen könnte. Der Eintrag war damit
// unreparierbar.
//
// Das Leck ist zu (riders.remove nimmt die Mitgliedschaft mit). Diese
// Migration räumt auf, was es hinterlassen hat, und stellt damit die Regel
// her, auf die sich die Oberfläche künftig verlassen darf:
//
// Wer im Team ist, steht im Kader.
//
// Der Name kommt vom Konto: erst der Kontoname, sonst der Teil der
// E-Mail-Adresse vor dem @. Beides kann später von der Teamleitung geändert
// werden — der Fahrername ist ab dann die einzige Wahrheit, der Kontoname
// taucht in der Oberfläche nirgends mehr auf.
migrate((app) => {
const teams = app.findRecordsByFilter('teams', 'id != ""', '', 0, 0)
let created = 0
for (const team of teams) {
// getStringSlice statt get: `get` gäbe bei einer Mehrfach-Relation ein
// Go-Slice zurück, auf dem sich weder includes noch map verlässlich
// verhalten.
const memberIds = team.getStringSlice('users') || []
if (memberIds.length === 0) continue
const taken = new Set()
for (const rider of app.findRecordsByFilter(
'riders',
'team = {:team} && user != ""',
'',
0,
0,
{ team: team.id },
)) {
taken.add(rider.getString('user'))
}
for (const userId of memberIds) {
if (taken.has(userId)) continue
let user
try {
user = app.findRecordById('users', userId)
} catch (err) {
// Konto gelöscht, die Relation zeigt ins Leere. Ein Fahrer für
// ein Konto, das es nicht gibt, wäre kein Gewinn.
continue
}
const email = user.getString('email')
const name =
user.getString('name').trim() ||
(email.indexOf('@') > 0 ? email.slice(0, email.indexOf('@')) : '') ||
'Ohne Namen'
const rider = new Record(app.findCollectionByNameOrId('riders'))
rider.set('team', team.id)
rider.set('user', userId)
rider.set('name', name)
app.save(rider)
taken.add(userId)
created++
}
}
console.log(`riders_for_orphan_members: ${created} Fahrer angelegt`)
}, (app) => {
// Kein Rückweg. Die hier angelegten Fahrer sind hinterher nicht mehr von
// denen zu unterscheiden, die die Teamleitung danach selbst angelegt hat —
// sie zu löschen träfe mit einiger Wahrscheinlichkeit die falschen, samt
// ihrer Teilnahmen und Zeiten. Ein Fahrer zu viel ist der kleinere Schaden.
})

View file

@ -1,45 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Aus einem Run wird eine Stage.
//
// „Run" hiess in dieser App immer schon der abgesteckte Abschnitt eines
// Events, auf dem gefahren und gestoppt wird — also das, was im Rennsport
// Stage heisst. „Run" ist daneben der einzelne Durchgang eines Fahrers, und
// genau der steht hier als `times`. Zwei Bedeutungen fuer ein Wort, an einer
// Stelle, an der beide vorkommen.
//
// Umbenannt wird, nicht neu angelegt: Collection und Feld behalten ihre IDs,
// PocketBase benennt Tabelle und Spalte um, die Daten bleiben stehen. Ueber
// die ID gesucht statt ueber den Namen, damit die Migration auch auf einer
// frischen Datenbank durchlaeuft, deren Snapshot die Collection bereits
// `stages` nennt.
const STAGES_COLLECTION_ID = 'jo49lycyfs2sz2c'
migrate((app) => {
const stages = app.findCollectionByNameOrId(STAGES_COLLECTION_ID)
if (stages.name !== 'stages') {
stages.name = 'stages'
app.save(stages)
}
const times = app.findCollectionByNameOrId('times')
const field = times.fields.getByName('run')
if (field) {
field.name = 'stage'
app.save(times)
}
}, (app) => {
const times = app.findCollectionByNameOrId('times')
const field = times.fields.getByName('stage')
if (field) {
field.name = 'run'
app.save(times)
}
const stages = app.findCollectionByNameOrId(STAGES_COLLECTION_ID)
if (stages.name !== 'runs') {
stages.name = 'runs'
app.save(stages)
}
})

View file

@ -118,30 +118,4 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
/*
* Tailwind 4 nimmt Knöpfen den Zeigefinger und überlässt sie dem
* Browser-Standard was am Ende ein Pfeil ist, der nichts verspricht.
* Hier steht er wieder, ein für alle Mal und nicht als Klasse an jedem
* einzelnen Element: Was sich anklicken lässt, sieht auch so aus.
*
* `label[for]` ist mitgemeint, weil versteckte Eingabefelder über ihr
* Label bedient werden etwa das Profilbild in den Einstellungen.
*/
button,
summary,
label[for],
[role='button'],
[role='menuitem'],
[role='option'],
[role='tab'] {
cursor: pointer;
}
/* Abgeschaltet heißt abgeschaltet — ein Zeigefinger verspräche dort zu viel. */
button:disabled,
[aria-disabled='true'],
[data-disabled] {
cursor: default;
}
} }

View file

@ -1,76 +0,0 @@
<script lang="ts">
/**
* Dialog „Neues Team". Liegt als eigene Komponente vor, weil es zwei
* Einstiege gibt — den Teamwechsler im Kopf und die Teamseite — und ein
* neues Team an beiden Stellen dasselbe bedeuten muss: Team anlegen, den
* Standardsatz Flag-Typen dazu, und ab dann ist es das aktive Team.
*
* Ein Team ohne Flag-Typen kann zwar Marker aufnehmen, aber nichts
* einordnen. Deshalb kommt der Standardsatz gleich mit — änderbar und,
* wie alle Flag-Typen, allein Sache dieses Teams.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { Button } from '@/components/ui/button'
import * as Dialog from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
let { open = $bindable(false) }: { open?: boolean } = $props()
const teams = getTeamContext()
const flags = getTrailFlagContext()
let name = $state('')
let error = $state<string | null>(null)
let busy = $state(false)
async function submit() {
if (!name.trim() || busy) return
busy = true
error = null
try {
const created = await teams.create(name.trim())
try {
await flags.seedDefaults(created.id)
} catch (e: any) {
error =
'Das Team wurde angelegt, die Standard-Flag-Typen aber nicht: ' +
(e.message ?? 'unbekannter Fehler') +
' Du kannst sie unter „Flag-Typen" nachholen.'
return
}
open = false
name = ''
} catch (e: any) {
error = e.message ?? 'Das Team konnte nicht angelegt werden.'
} finally {
busy = false
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neues Team</Dialog.Title>
<Dialog.Description>Du wirst automatisch Owner.</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-name">Name</Label>
<Input id="team-name" bind:value={name} placeholder="Mein Team" onkeydown={(e) => e.key === 'Enter' && submit()} />
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
<Button onclick={submit} disabled={!name.trim() || busy}>Erstellen</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -20,9 +20,9 @@
<div class="relative {className}" id="logo"> <div class="relative {className}" id="logo">
{#if url} {#if url}
<img alt="" class="absolute h-full w-full object-contain" src={url} /> <img alt="" class="absolute inset-0 h-full w-full object-contain" src={url} />
{:else} {:else}
<img alt="" class="absolute h-full w-full object-contain" src="/schildl.svg"> <img alt="" class="absolute inset-0 h-full w-full object-contain" src="/schildl.svg">
<img alt="" class="absolute h-full w-full scale-110 object-contain" src="/radl.svg"> <img alt="" class="absolute inset-0 h-full w-full scale-110 object-contain" src="/radl.svg">
{/if} {/if}
</div> </div>

View file

@ -3,17 +3,17 @@
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange * Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen * irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr * zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Stage-Seite. * bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Run-Seite.
* *
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die * Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen. * längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
* *
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den * Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
* Zeit-Dialog mit Fahrer und Stage vorbelegt. * Zeit-Dialog mit Fahrer und Run vorbelegt.
*/ */
import { getTimeContext } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getStageContext } from '$lib/stores/stages.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte' import { ticker } from '$lib/stores/ticker.svelte'
import TimeDialog from './TimeDialog.svelte' import TimeDialog from './TimeDialog.svelte'
@ -22,7 +22,7 @@
const times = getTimeContext() const times = getTimeContext()
const riders = getRiderContext() const riders = getRiderContext()
const stages = getStageContext() const runs = getRunContext()
const participants = getEventParticipantContext() const participants = getEventParticipantContext()
const clock = ticker() const clock = ticker()
@ -35,15 +35,15 @@
let timeDialog = $state(false) let timeDialog = $state(false)
let dialogEvent = $state('') let dialogEvent = $state('')
let dialogRider = $state<string | null>(null) let dialogRider = $state<string | null>(null)
let dialogStage = $state<string | null>(null) let dialogRun = $state<string | null>(null)
function openTimeDialog(riderId: string | undefined, stageId: string | undefined) { function openTimeDialog(riderId: string | undefined, runId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined const run = runId ? runs.getById(runId) : undefined
if (!stage?.event) return if (!run?.event) return
dialogEvent = stage.event dialogEvent = run.event
dialogRider = riderId ?? null dialogRider = riderId ?? null
dialogStage = stage.id dialogRun = run.id
timeDialog = true timeDialog = true
} }
@ -51,25 +51,25 @@
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal. // Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
const open = $derived(single || expanded) const open = $derived(single || expanded)
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */ /** Die Startnummer gilt je Event, deshalb der Umweg über den Run. */
function riderLabel(riderId: string | undefined, stageId: string | undefined) { function riderLabel(riderId: string | undefined, runId: string | undefined) {
const eventId = stageId ? stages.getById(stageId)?.event : undefined const eventId = runId ? runs.getById(runId)?.event : undefined
if (!eventId) { if (!eventId) {
const r = riderId ? riders.getById(riderId) : undefined const r = riderId ? riders.getById(riderId) : undefined
return r ? riderName(r) : 'Unbekannter Fahrer' return r ? fullName(r) : 'Unbekannter Fahrer'
} }
return participants.riderLabel(eventId, riderId) return participants.riderLabel(eventId, riderId)
} }
function stagePath(stageId: string | undefined) { function runPath(runId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined const run = runId ? runs.getById(runId) : undefined
if (!stage?.event) return null if (!run?.event) return null
return `/dashboard/events/${stage.event}/stages/${stage.id}` return `/dashboard/events/${run.event}/runs/${run.id}`
} }
function stageLabel(stageId: string | undefined) { function runLabel(runId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined const run = runId ? runs.getById(runId) : undefined
return stage?.description || 'Stage' return run?.description || 'Run'
} }
</script> </script>
@ -113,15 +113,15 @@
{#if open} {#if open}
<ul class="divide-y max-h-[50vh] overflow-y-auto"> <ul class="divide-y max-h-[50vh] overflow-y-auto">
{#each running as t (t.id)} {#each running as t (t.id)}
{@const path = stagePath(t.stage)} {@const path = runPath(t.run)}
<li class="flex items-center gap-2 px-3 py-2"> <li class="flex items-center gap-2 px-3 py-2">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{riderLabel(t.rider, t.stage)}</p> <p class="text-sm font-medium truncate">{riderLabel(t.rider, t.run)}</p>
<p class="text-xs text-muted-foreground truncate"> <p class="text-xs text-muted-foreground truncate">
{#if path} {#if path}
<a href={path} class="hover:underline">{stageLabel(t.stage)}</a> <a href={path} class="hover:underline">{runLabel(t.run)}</a>
{:else} {:else}
{stageLabel(t.stage)} {runLabel(t.run)}
{/if} {/if}
</p> </p>
</div> </div>
@ -135,7 +135,7 @@
size="icon" size="icon"
class="shrink-0" class="shrink-0"
tooltip="Zeit erfassen" tooltip="Zeit erfassen"
onclick={() => openTimeDialog(t.rider, t.stage)} onclick={() => openTimeDialog(t.rider, t.run)}
> >
<Timer class="size-4" /> <Timer class="size-4" />
</Button> </Button>
@ -150,6 +150,6 @@
bind:open={timeDialog} bind:open={timeDialog}
eventId={dialogEvent} eventId={dialogEvent}
riderId={dialogRider} riderId={dialogRider}
stageId={dialogStage} runId={dialogRun}
/> />
{/if} {/if}

View file

@ -1,39 +0,0 @@
<script lang="ts">
/**
* Ladeanzeige als Mountainbike-Laufrad: Stollenreifen, Felge, gekreuzte
* Speichen, Nabe. Das Ventil sitzt bewusst nur an einer Stelle — ohne diese
* Unwucht sieht ein achtfach symmetrisches Rad im Stillstand aus, egal wie
* schnell es sich dreht.
*
* Zeichnet in `currentColor` und richtet sich nach der uebergebenen
* Groessenklasse, laesst sich also wie ein Lucide-Icon einsetzen.
*/
let { class: className = 'size-6' }: { class?: string } = $props()
</script>
<svg
aria-hidden="true"
class="animate-spin {className}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Stollen: 14 Segmente auf dem Umfang 2πr = 60.95, also je 4.35 breit -->
<circle cx="12" cy="12" r="9.7" stroke-dasharray="1.7 2.654" stroke-width="2.6" />
<!-- Reifen -->
<circle cx="12" cy="12" r="9.4" stroke-width="1.8" />
<!-- Felge -->
<circle cx="12" cy="12" r="7.3" stroke-width="1.1" />
<!-- Speichen, gekreuzt gespannt wie an einem echten Laufrad -->
<path
d="M12.88 10.2L12 4.7M12.65 10.11L17.16 6.84M13.8 12.88L19.3 12M13.89 12.65L17.16 17.16M11.12 13.8L12 19.3M11.35 13.89L6.84 17.16M10.2 11.12L4.7 12M10.11 11.35L6.84 6.84"
opacity=".5"
stroke-linecap="round"
stroke-width=".8"
/>
<!-- Nabe -->
<circle cx="12" cy="12" fill="currentColor" r="1.9" stroke="none" />
<!-- Ventil -->
<path d="M12 8.4L12 6.6" stroke-linecap="round" stroke-width="1.3" />
</svg>

View file

@ -1,137 +0,0 @@
<script lang="ts">
/**
* Der Menuepunkt „Team" in der Kopfleiste — ein Menuepunkt, der sich beim
* Ueberfahren aufklappt: darunter der Wechsel zwischen den eigenen Teams
* und der Weg zu einem neuen.
*
* Der Punkt selbst ist ein Link auf die Teamseite. Ein eigener Eintrag
* „Uebersicht" waere die zweite Tuer zu derselben Seite, und eine
* Ueberschrift ueber drei Teamnamen sagt nichts, was die Namen nicht
* schon sagen.
*
* Auf dem Touchscreen gibt es kein Ueberfahren: Dort fuehrt der Tipp auf
* die Teamseite, und gewechselt wird in deren Liste „Meine Teams".
*/
import { goto } from '$app/navigation'
import { getTeamContext } from '$lib/stores/teams.svelte'
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
import * as DropdownMenu from '@/components/ui/dropdown-menu'
import { ChevronDown, Plus, Users } from 'lucide-svelte'
let {
class: className = '',
onnavigate,
}: { class?: string; onnavigate?: () => void } = $props()
const teams = getTeamContext()
let createDialog = $state(false)
let menuOpen = $state(false)
// Zwischen Knopf und aufgeklapptem Menue liegt ein Spalt. Ohne die kurze
// Frist fiele das Menue zu, sobald der Zeiger ihn ueberquert.
let closeTimer: ReturnType<typeof setTimeout> | null = null
function enter() {
if (closeTimer) clearTimeout(closeTimer)
menuOpen = true
}
function leave() {
if (closeTimer) clearTimeout(closeTimer)
closeTimer = setTimeout(() => (menuOpen = false), 150)
}
/**
* Team wechseln heisst hier auch: hinsehen. Der Wechsel allein aendert
* bloss still, was jede Liste zeigt — die Teamseite macht sichtbar,
* worauf man gewechselt hat.
*/
function switchTo(id: string) {
teams.setActive(id)
goto('/dashboard/team')
onnavigate?.()
}
</script>
<DropdownMenu.Root bind:open={menuOpen}>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<!--
Die Zeiger- und Tastenhandler von bits-ui werden bewusst
ueberschrieben: Deren onclick ruft preventDefault und macht
den Link damit tot, und ihr pointerdown klappt das Menue zu,
das der Hover gerade aufgeklappt hat — zwei Herren ueber
dasselbe `open`, das Ergebnis war Flackern statt Navigation.
Hier klappt allein der Hover auf, und der Klick folgt dem
Link wie bei jedem anderen Menuepunkt.
-->
<a
{...props}
href="/dashboard/team"
class={className}
onmouseenter={enter}
onmouseleave={leave}
onpointerdown={undefined}
onpointerup={undefined}
onkeydown={undefined}
onclick={() => {
menuOpen = false
onnavigate?.()
}}
>
<Users class="h-4 w-4" />
<span>Team</span>
<ChevronDown class="h-3 w-3 opacity-50" />
</a>
{/snippet}
</DropdownMenu.Trigger>
<!--
Aufgeklappt wird beim Ueberfahren, nicht auf Ansage: Das Menue darf
deshalb weder den Fokus an sich ziehen noch das Scrollen sperren —
beides gehoert zu einem Dialog, nicht zu einem Menuepunkt, ueber den
der Zeiger nur streift.
Auch der Fokus beim Schliessen bleibt aus. bits-ui gibt ihn sonst an
den Trigger zurueck, und weil hier der Zeiger schliesst und nicht die
Tastatur, stuende danach ein Fokusring auf dem Menuepunkt — fuer
jemanden, der nur darueber gefahren ist.
-->
<DropdownMenu.Content
align="start"
class="min-w-56"
trapFocus={false}
preventScroll={false}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onmouseenter={enter}
onmouseleave={leave}
>
{#each teams.records as t (t.id)}
<DropdownMenu.Item
onclick={() => switchTo(t.id)}
class={t.id === teams.activeId ? 'bg-accent' : ''}
>
<span class="flex-1">{t.name}</span>
{#if teams.isOwner(t)}
<span class="text-xs text-muted-foreground">Owner</span>
{:else if teams.isAdmin(t)}
<span class="text-xs text-muted-foreground">Admin</span>
{/if}
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item disabled>Noch in keinem Team</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => (createDialog = true)}>
<Plus class="h-4 w-4 mr-2" />
Team erstellen
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Ausserhalb des Menues: mit dem Klick schliesst sich das Dropdown, und ein
darin gerendeter Dialog verschwaende mit ihm. -->
<CreateTeamDialog bind:open={createDialog} />

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { getTeamContext } from '$lib/stores/teams.svelte'
import { Button } from '@/components/ui/button'
import * as DropdownMenu from '@/components/ui/dropdown-menu'
import { ChevronDown, Plus, Users } from 'lucide-svelte'
import { goto } from '$app/navigation'
const teams = getTeamContext()
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="gap-2">
<Users class="h-4 w-4" />
<span class="font-medium">{teams.active?.name ?? 'Kein Team'}</span>
<ChevronDown class="h-3 w-3 opacity-50" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="min-w-56">
<DropdownMenu.Label>Teams wechseln</DropdownMenu.Label>
{#each teams.records as t}
{@const isMe = teams.isOwner(t) || teams.isAdmin(t)}
<DropdownMenu.Item
onclick={() => teams.setActive(t.id)}
class={t.id === teams.activeId ? 'bg-accent' : ''}
>
<span class="flex-1">{t.name}</span>
{#if teams.isOwner(t)}
<span class="text-xs text-muted-foreground">Owner</span>
{:else if teams.isAdmin(t)}
<span class="text-xs text-muted-foreground">Admin</span>
{/if}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => goto('/dashboard/settings/team')}>
<Users class="h-4 w-4 mr-2" />
Teams verwalten
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>

View file

@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
/** /**
* Der eine Dialog für die Zeitnahme. Fahrer und Stage stehen immer beide * Der eine Dialog für die Zeitnahme. Fahrer und Run stehen immer beide
* darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit * darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man * beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder * gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
* Tabellen noch die Toast-Leiste lösen sie direkt aus. * Tabellen noch die Toast-Leiste lösen sie direkt aus.
*/ */
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte' import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getStageContext } from '$lib/stores/stages.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte' import { ticker } from '$lib/stores/ticker.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -21,24 +21,24 @@
open = $bindable(false), open = $bindable(false),
eventId, eventId,
riderId = null, riderId = null,
stageId = null, runId = null,
}: { }: {
open?: boolean open?: boolean
/** Event, aus dem Fahrer und Stages zur Auswahl stehen. */ /** Event, aus dem Fahrer und Runs zur Auswahl stehen. */
eventId: string eventId: string
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */ /** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
riderId?: string | null riderId?: string | null
stageId?: string | null runId?: string | null
} = $props() } = $props()
const times = getTimeContext() const times = getTimeContext()
const riders = getRiderContext() const riders = getRiderContext()
const stages = getStageContext() const runs = getRunContext()
const participants = getEventParticipantContext() const participants = getEventParticipantContext()
const clock = ticker() const clock = ticker()
let selectedRider = $state('') let selectedRider = $state('')
let selectedStage = $state('') let selectedRun = $state('')
let error = $state<string | null>(null) let error = $state<string | null>(null)
let confirmDnf = $state(false) let confirmDnf = $state(false)
let busy = $state(false) let busy = $state(false)
@ -49,7 +49,7 @@
if (!open) return if (!open) return
selectedRider = riderId ?? '' selectedRider = riderId ?? ''
selectedStage = stageId ?? '' selectedRun = runId ?? ''
error = null error = null
confirmDnf = false confirmDnf = false
}) })
@ -63,16 +63,16 @@
.filter((r) => !!r) .filter((r) => !!r)
: [], : [],
) )
const eventStages = $derived(eventId ? stages.getByEvent(eventId) : []) const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
const riderLabel = $derived( const riderLabel = $derived(
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…', selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
) )
const stageLabel = $derived( const runLabel = $derived(
selectedStage ? stages.getById(selectedStage)?.description ?? 'Stage wählen…' : 'Stage wählen…', selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
) )
const ready = $derived(!!selectedRider && !!selectedStage) const ready = $derived(!!selectedRider && !!selectedRun)
// Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und // Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und
// verschwindet, sobald jemand anders dieselbe Zeit stoppt. // verschwindet, sobald jemand anders dieselbe Zeit stoppt.
@ -80,14 +80,14 @@
ready ready
? times ? times
.allRunning(clock.now) .allRunning(clock.now)
.find((t) => t.stage === selectedStage && t.rider === selectedRider) .find((t) => t.run === selectedRun && t.rider === selectedRider)
: undefined, : undefined,
) )
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */ /** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
const existing = $derived( const existing = $derived(
ready && !active ready && !active
? times.getByStage(selectedStage).find((t) => t.rider === selectedRider && t.stop) ? times.getByRun(selectedRun).find((t) => t.rider === selectedRider && t.stop)
: undefined, : undefined,
) )
@ -97,7 +97,7 @@
busy = true busy = true
error = null error = null
try { try {
await times.start(selectedStage, selectedRider) await times.start(selectedRun, selectedRider)
open = false open = false
} catch (e: any) { } catch (e: any) {
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.' error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
@ -128,7 +128,7 @@
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Zeit erfassen</Dialog.Title> <Dialog.Title>Zeit erfassen</Dialog.Title>
<Dialog.Description> <Dialog.Description>
Fahrer und Stage wählen — je nach Stand wird gestartet oder gestoppt. Fahrer und Run wählen — je nach Stand wird gestartet oder gestoppt.
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
@ -151,17 +151,17 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Stage</Label> <Label>Run</Label>
<Select.Root type="single" bind:value={selectedStage} disabled={eventStages.length === 0}> <Select.Root type="single" bind:value={selectedRun} disabled={eventRuns.length === 0}>
<Select.Trigger class="w-full">{stageLabel}</Select.Trigger> <Select.Trigger class="w-full">{runLabel}</Select.Trigger>
<Select.Content> <Select.Content>
{#each eventStages as r (r.id)} {#each eventRuns as r (r.id)}
<Select.Item value={r.id}>{r.description || 'Stage'}</Select.Item> <Select.Item value={r.id}>{r.description || 'Run'}</Select.Item>
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
{#if eventStages.length === 0} {#if eventRuns.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Stages.</p> <p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Runs.</p>
{/if} {/if}
</div> </div>

View file

@ -25,6 +25,7 @@
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx' import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx'
import FlagIcon from './FlagIcon.svelte' import FlagIcon from './FlagIcon.svelte'
import { trailStatusColor } from '$lib/trailStatus' import { trailStatusColor } from '$lib/trailStatus'
import { env } from '$env/dynamic/public'
import { mode } from 'mode-watcher' import { mode } from 'mode-watcher'
maplibregl.setWorkerUrl(workerUrl) maplibregl.setWorkerUrl(workerUrl)
@ -83,24 +84,29 @@
} = $props() } = $props()
/** /**
* Basiskarten von OpenFreeMap: zurückhaltend gezeichnet — die Trail-Linie * Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen
* liegt dadurch deutlich sichtbarer darüber — und in einer echten dunklen * keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt
* Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein dunkleres * dadurch deutlich sichtbarer darüber — und es gibt sie in einer echten
* Bild, keine dunkle Karte. * dunklen Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein
* dunkleres Bild, keine dunkle Karte.
* *
* Vorher lagen hier die Rasterkacheln von CARTO. Die kommen inzwischen * Mehrere Subdomains, weil Browser die Verbindungen pro Host begrenzen.
* mit einem eingebrannten „API KEY REQUIRED" quer über der Karte — mit
* HTTP 200, weshalb es wie ein Darstellungsfehler aussah und nicht wie
* eine Sperre. Der Schlüssel aus PUBLIC_CARTO_API_KEY änderte daran
* nichts: Die Kachel kam byteidentisch zurück wie ohne ihn.
*
* OpenFreeMap braucht keinen Schlüssel und kennt kein Limit; die Stile
* heißen dort positron und dark und sind genau die, die CARTO als
* Positron und Dark Matter geprägt hat. Die Quellenangabe steckt in der
* Stildatei, sie muss hier nicht mehr von Hand gesetzt werden.
*/ */
const STYLE_LIGHT = 'https://tiles.openfreemap.org/styles/positron' // Der Schlüssel muss den Browser erreichen — er kommt deshalb aus den
const STYLE_DARK = 'https://tiles.openfreemap.org/styles/dark' // PUBLIC_-Variablen. Über $env/dynamic/public statt static, weil die
// Kacheln auch ohne Schlüssel ausgeliefert werden: Fehlt die Variable,
// soll der Build nicht abbrechen.
const CARTO_KEY = env.PUBLIC_CARTO_API_KEY ?? ''
const KEY_PARAM = CARTO_KEY ? `?key=${CARTO_KEY}` : ''
const cartoTiles = (style: 'light_all' | 'dark_all') =>
['a', 'b', 'c'].map(
(sub) => `https://${sub}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}.png${KEY_PARAM}`,
)
const TILES_LIGHT = cartoTiles('light_all')
const TILES_DARK = cartoTiles('dark_all')
const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende, &copy; CARTO'
const isDark = $derived(mode.current === 'dark') const isDark = $derived(mode.current === 'dark')
@ -137,7 +143,18 @@
function build() { function build() {
const instance = new maplibregl.Map({ const instance = new maplibregl.Map({
container, container,
style: isDark ? STYLE_DARK : STYLE_LIGHT, style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: isDark ? TILES_DARK : TILES_LIGHT,
tileSize: 256,
attribution: ATTRIBUTION,
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
},
center: FALLBACK_CENTER, center: FALLBACK_CENTER,
zoom: 11, zoom: 11,
attributionControl: controls ? undefined : false, attributionControl: controls ? undefined : false,
@ -149,13 +166,8 @@
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left') instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
} }
// `style.load` statt `load`: Es feuert auch nach jedem instance.on('load', () => {
// setStyle. Ein Stilwechsel wirft Quellen und Layer weg, die
// Trail-Linie muss danach neu entstehen — die Marker nicht, die
// haengen als DOM-Elemente an der Karte und nicht am Stil.
instance.on('style.load', () => {
ready = true ready = true
drawTrail(instance)
}) })
cleanupMap = () => { cleanupMap = () => {
@ -240,26 +252,18 @@
}) })
} }
/** // Trail-Linie zeichnen bzw. aktualisieren
* Die layerbezogenen Listener bleiben am Kartenobjekt haengen, auch wenn $effect(() => {
* ihr Layer beim Stilwechsel verschwindet und gleich darauf wieder if (!map || !ready) return
* entsteht. Ein zweites bindTrailEvents wuerde sie deshalb nur
* verdoppeln.
*/
let trailEventsBound = false
/**
* Trail-Linie zeichnen bzw. aktualisieren. Zwei Aufrufer: der Effekt, wenn
* sich Strecke oder Zustand aendern, und `style.load` nach jedem
* Stilwechsel — dann ist die Quelle fort und muss neu angelegt werden.
*/
function drawTrail(instance: MapLibreMap) {
const data = geojson const data = geojson
const color = trailStatusColor(status) const color = trailStatusColor(status)
const src = instance.getSource('trail') as maplibregl.GeoJSONSource | undefined const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
if (!data) { if (!data) {
src?.setData({ type: 'FeatureCollection', features: [] }) if (src) {
src.setData({ type: 'FeatureCollection', features: [] })
}
return return
} }
@ -267,46 +271,33 @@
if (src) { if (src) {
src.setData(feature) src.setData(feature)
instance.setPaintProperty('trail-line', 'line-color', color) map.setPaintProperty('trail-line', 'line-color', color)
return } else {
} map.addSource('trail', { type: 'geojson', data: feature })
map.addLayer({
instance.addSource('trail', { type: 'geojson', data: feature })
instance.addLayer({
id: 'trail-line', id: 'trail-line',
type: 'line', type: 'line',
source: 'trail', source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' }, layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 4 }, paint: { 'line-color': color, 'line-width': 4 },
}) })
instance.addLayer({ map.addLayer({
id: 'trail-hit', id: 'trail-hit',
type: 'line', type: 'line',
source: 'trail', source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' }, layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 }, paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 },
}) })
bindTrailEvents(map)
if (!trailEventsBound) {
bindTrailEvents(instance)
trailEventsBound = true
} }
}
// Trail-Linie zeichnen bzw. aktualisieren
$effect(() => {
if (!map || !ready) return
drawTrail(map)
}) })
/** /** Beim Themenwechsel die Kachelquelle austauschen. */
* Beim Themenwechsel den ganzen Stil tauschen. Kacheln allein tun es
* nicht: Hell und dunkel unterscheiden sich hier nicht im Bild, sondern
* in der Zeichenvorschrift.
*/
$effect(() => { $effect(() => {
if (!map || !ready) return if (!map || !ready) return
map.setStyle(isDark ? STYLE_DARK : STYLE_LIGHT)
const src = map.getSource('osm') as maplibregl.RasterTileSource | undefined
src?.setTiles(isDark ? TILES_DARK : TILES_LIGHT)
}) })
// Kartenausschnitt auf den Track setzen // Kartenausschnitt auf den Track setzen

View file

@ -1,7 +1,7 @@
import { getContext, setContext } from 'svelte' import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte' import { api, auth } from './pocketbase.svelte'
import { getEventContext } from './events.svelte' import { getEventContext } from './events.svelte'
import { getRiderContext, riderName } from './riders.svelte' import { getRiderContext } from './riders.svelte'
import type { EventParticipantsResponse, RidersResponse } from '$lib/types' import type { EventParticipantsResponse, RidersResponse } from '$lib/types'
const KEY = Symbol('eventParticipant') const KEY = Symbol('eventParticipant')
@ -45,7 +45,7 @@ export class EventParticipantStore {
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb
if (Number.isFinite(na)) return -1 if (Number.isFinite(na)) return -1
if (Number.isFinite(nb)) return 1 if (Number.isFinite(nb)) return 1
return this.nameOf(a.rider).localeCompare(this.nameOf(b.rider)) return this.riderName(a.rider).localeCompare(this.riderName(b.rider))
}) })
} }
@ -94,15 +94,16 @@ export class EventParticipantStore {
riderLabel(eventId: string, riderId: string | undefined): string { riderLabel(eventId: string, riderId: string | undefined): string {
if (!riderId) return 'Unbekannter Fahrer' if (!riderId) return 'Unbekannter Fahrer'
const name = this.nameOf(riderId) || 'Unbekannter Fahrer' const name = this.riderName(riderId) || 'Unbekannter Fahrer'
const number = this.numberOf(eventId, riderId) const number = this.numberOf(eventId, riderId)
return number ? `#${number} ${name}` : name return number ? `#${number} ${name}` : name
} }
private nameOf(riderId: string): string { private riderName(riderId: string): string {
const r = this.riders.getById(riderId) const r = this.riders.getById(riderId)
return r ? riderName(r) : '' if (!r) return ''
return [r.firstname, r.lastname].filter(Boolean).join(' ') || r.name || ''
} }
async load() { async load() {

View file

@ -27,19 +27,19 @@ class AuthStore {
api.authStore.loadFromCookie(cookie) api.authStore.loadFromCookie(cookie)
} }
/**
* Anmelden. Ohne Argumente gelten die Felder des Stores, die das
* Login-Formular füllt; mit Argumenten die übergebenen die
* Einladungsseite meldet damit das eben angelegte Konto an, ohne den
* Umweg über die Formularfelder.
*/
login = async (user = auth.username, password = auth.password) => { login = async (user = auth.username, password = auth.password) => {
this.loading = true this.loading = true
this.error = '' this.error = ''
if (this.error) {
this.loading = false
return
}
try { try {
await api.collection('users').authWithPassword(user, password, this.params) await api.collection('users').authWithPassword(auth.username, auth.password, this.params)
this.user = api.authStore.record as AuthRecord | null this.user = api.authStore.record as AuthRecord | null

View file

@ -11,20 +11,12 @@ const KEY = Symbol('rider')
* Die gelten seit 1754501300 je Event und stehen am Teilnehmer. * Die gelten seit 1754501300 je Event und stehen am Teilnehmer.
*/ */
function sortByName(records: RidersResponse[]) { function sortByName(records: RidersResponse[]) {
return [...records].sort((a, b) => riderName(a).localeCompare(riderName(b))) return [...records].sort((a, b) => fullName(a).localeCompare(fullName(b)))
} }
/** export function fullName(r: RidersResponse) {
* Der Name eines Fahrers ein Feld, ein Wert. const parts = [r.firstname, r.lastname].filter(Boolean)
* return parts.join(' ').trim() || r.name || ''
* Bis 1754501800_riders_single_name.js standen hier Vorname und Nachname
* getrennt und mussten überall wieder zusammengesetzt werden. Die Funktion
* bleibt trotzdem: Sie ist die eine Stelle, an der ein namenloser Fahrer zu
* einem leeren String wird, statt als `undefined` durch die Oberfläche zu
* geistern.
*/
export function riderName(r: RidersResponse) {
return (r.name ?? '').trim()
} }
export class RiderStore { export class RiderStore {
@ -92,47 +84,13 @@ export class RiderStore {
return await api.collection('riders').update(id, data) return await api.collection('riders').update(id, data)
} }
/**
* Fahrer löschen und mit ihm die Mitgliedschaft seines Kontos.
*
* Ohne den zweiten Schritt riss jedes Löschen ein Loch: Der Fahrer war
* fort, sein Konto blieb in `teams.users` und stand danach als Zeile ohne
* Fahrer im Kader. Aus so einer Zeile führte kein Weg zurück ein Konto
* nachträglich an einen Fahrer zu hängen gibt es bewusst nicht, weil
* dieselbe Person sonst zweimal in der Liste stehen könnte. Der Eintrag
* war damit unreparierbar.
*
* Das Konto selbst bleibt bestehen: Löschen darf es allein sein Inhaber
* (`users.deleteRule = id = @request.auth.id`), und über einen neuen
* Einladungslink kommt es zurück ins Team.
*
* Der Owner ist ausgenommen. Ihn aus `teams.users` zu nehmen, nähme ihm
* die Sicht auf sein eigenes Team die listRule der meisten Collections
* hängt an `team.users`, nicht an `team.owner`.
*/
remove(record: RidersResponse) { remove(record: RidersResponse) {
const team = this.teams.records.find((t) => t.id === record.team)
const userId = record.user || null
const dropsMembership = !!userId && !!team && userId !== team.owner
const text = dropsMembership
? `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht. ` +
`Das zugehörige Konto verliert seine Mitgliedschaft im Team — gelöscht wird es nicht, es kann über einen Einladungslink zurückkommen.`
: `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
app.confirm.request({ app.confirm.request({
title: 'Fahrer löschen?', title: 'Fahrer löschen?',
text, text: `Möchten Sie „${fullName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`,
yes: async () => { yes: async () => {
try { try {
// Erst die Mitgliedschaft, dann der Fahrer. Andersherum
// stünde bei einem Fehler im zweiten Schritt genau das
// verwaiste Konto da, das hier verhindert werden soll.
if (dropsMembership) {
await this.teams.removeMember(team!.id, userId!)
}
await api.collection('riders').delete(record.id) await api.collection('riders').delete(record.id)
app.confirm.close() app.confirm.close()
resolve() resolve()
@ -158,14 +116,9 @@ export class RiderStore {
return this.scoped.filter((r) => r.user === userId) return this.scoped.filter((r) => r.user === userId)
} }
/** /** Bestehendes Konto zuordnen oder die Zuordnung lösen. */
* Konto an den Fahrer hängen. Nur intern und nur in diese Richtung: Ein async linkUser(riderId: string, userId: string | null) {
* Konto entsteht am Fahrer, ein Lösen der Verbindung gibt es nicht sonst return await this.edit(riderId, { user: userId ?? '' } as Partial<RidersResponse>)
* stünde dieselbe Person zweimal in der Liste, einmal mit und einmal ohne
* Login.
*/
private async linkUser(riderId: string, userId: string) {
return await this.edit(riderId, { user: userId } as Partial<RidersResponse>)
} }
/** /**
@ -178,7 +131,7 @@ export class RiderStore {
): Promise<UsersResponse> { ): Promise<UsersResponse> {
const user = await this.teams.createMember({ const user = await this.teams.createMember({
...data, ...data,
name: data.name?.trim() || riderName(rider), name: data.name?.trim() || fullName(rider),
}) })
await this.linkUser(rider.id, user.id) await this.linkUser(rider.id, user.id)
@ -189,7 +142,13 @@ export class RiderStore {
get filtered() { get filtered() {
const q = this.searchQuery.toLowerCase().trim() const q = this.searchQuery.toLowerCase().trim()
if (!q) return this.scoped if (!q) return this.scoped
return this.scoped.filter((r) => riderName(r).toLowerCase().includes(q)) return this.scoped.filter((r) => {
return (
(r.firstname ?? '').toLowerCase().includes(q) ||
(r.lastname ?? '').toLowerCase().includes(q) ||
(r.name ?? '').toLowerCase().includes(q)
)
})
} }
destroy() { destroy() {

View file

@ -2,19 +2,19 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte' import { api } from './pocketbase.svelte'
import { app } from './app.svelte' import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte' import { getTeamContext } from './teams.svelte'
import type { StagesResponse } from '$lib/types' import type { RunsResponse } from '$lib/types'
const KEY = Symbol('stage') const KEY = Symbol('run')
export class StageStore { export class RunStore {
records = $state<StagesResponse[]>([]) records = $state<RunsResponse[]>([])
loading = $state(false) loading = $state(false)
error = $state<string | null>(null) error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null private unsubscribe: (() => void) | null = null
private teams = getTeamContext() private teams = getTeamContext()
get scoped(): StagesResponse[] { get scoped(): RunsResponse[] {
const teamId = this.teams.activeId const teamId = this.teams.activeId
if (!teamId) return [] if (!teamId) return []
return this.records.filter((r) => r.team === teamId) return this.records.filter((r) => r.team === teamId)
@ -24,12 +24,12 @@ export class StageStore {
this.loading = true this.loading = true
this.error = null this.error = null
try { try {
this.records = await api.collection('stages').getFullList({ this.records = await api.collection('runs').getFullList({
sort: '+created', sort: '+created',
requestKey: null, requestKey: null,
}) })
} catch (e: any) { } catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Stages' this.error = e.message ?? 'Fehler beim Laden der Runs'
console.error(e) console.error(e)
} finally { } finally {
this.loading = false this.loading = false
@ -38,34 +38,34 @@ export class StageStore {
subscribe() { subscribe() {
if (this.unsubscribe) return if (this.unsubscribe) return
api.collection('stages').subscribe('*', (e) => { api.collection('runs').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id) const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record] 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 === '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) else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}).then((unsub) => { }).then((unsub) => {
this.unsubscribe = unsub this.unsubscribe = unsub
}).catch((e) => console.warn('stages subscribe failed:', e)) }).catch((e) => console.warn('runs subscribe failed:', e))
} }
async create(data: Partial<StagesResponse>) { async create(data: Partial<RunsResponse>) {
const teamId = this.teams.activeId const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team') if (!teamId) throw new Error('Kein aktives Team')
return await api.collection('stages').create({ ...data, team: teamId }) return await api.collection('runs').create({ ...data, team: teamId })
} }
async edit(id: string, data: Partial<StagesResponse>) { async edit(id: string, data: Partial<RunsResponse>) {
return await api.collection('stages').update(id, data) return await api.collection('runs').update(id, data)
} }
remove(record: StagesResponse) { remove(record: RunsResponse) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
app.confirm.request({ app.confirm.request({
title: 'Stage löschen?', title: 'Run löschen?',
text: `Möchten Sie „${record.description}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`, text: `Möchten Sie „${record.description}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`,
yes: async () => { yes: async () => {
try { try {
await api.collection('stages').delete(record.id) await api.collection('runs').delete(record.id)
app.confirm.close() app.confirm.close()
resolve() resolve()
} catch (e) { } catch (e) {
@ -97,12 +97,12 @@ export class StageStore {
} }
} }
export function setStageContext() { export function setRunContext() {
const store = new StageStore() const store = new RunStore()
setContext(KEY, store) setContext(KEY, store)
return store return store
} }
export function getStageContext(): StageStore { export function getRunContext(): RunStore {
return getContext<StageStore>(KEY) return getContext<RunStore>(KEY)
} }

View file

@ -1,193 +0,0 @@
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import type { TeamInvitesResponse } from '$lib/types'
const KEY = Symbol('teamInvite')
/**
* Einladungslinks eines Teams.
*
* Zwei Seiten, zwei Wege: Die Teamleitung legt Links über die Collection an
* und sieht sie dort das regeln die Regeln in
* 1754501600_team_invites.js. Wer einen Link einlöst, ist noch nicht
* eingeloggt und läuft über die öffentlichen Routen aus
* backend/pb_hooks/team_invites.pb.js; die stehen unten als `invites.public`.
*/
export class TeamInviteStore {
records = $state<TeamInvitesResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
get scoped(): TeamInvitesResponse[] {
const teamId = this.teams.activeId
if (!teamId) return []
return this.records.filter((r) => r.team === teamId)
}
/**
* Einladungen verwaltet nur die Teamleitung dieselbe Regel wie in
* list/create/deleteRule der Collection. Die Prüfung hier blendet nur UI
* aus; durchgesetzt wird sie serverseitig.
*/
get canManage(): boolean {
return this.teams.isAdmin()
}
/** Gilt der Link noch? Abgelaufen und aufgebraucht sehen gleich aus. */
isValid(invite: TeamInvitesResponse): boolean {
if (invite.expires && new Date(invite.expires).getTime() < Date.now()) return false
const max = invite.max_uses ?? 0
return !(max > 0 && (invite.uses ?? 0) >= max)
}
/** Der Link zum Weitergeben. Ohne Fenster (SSR) gibt es keinen Host. */
url(invite: TeamInvitesResponse): string {
const origin = typeof location !== 'undefined' ? location.origin : ''
return `${origin}/invite/${invite.token}`
}
async load() {
// Ein einfaches Mitglied bekäme hier ohnehin eine leere Liste; der
// Aufruf entfällt trotzdem, damit das Dashboard beim Start eine
// Anfrage weniger stellt.
if (!auth.user) return
this.loading = true
this.error = null
try {
this.records = await api.collection('team_invites').getFullList({
sort: '-created',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Einladungen'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('team_invites').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('team_invites subscribe failed:', e))
}
/**
* Neuen Link erzeugen. Den Token setzt PocketBase selbst ihn
* mitzuschicken lehnt die createRule ab, damit sich niemand einen leicht
* zu ratenden aussuchen kann.
*
* `days` 0 heißt: läuft nicht ab, `maxUses` 0: beliebig oft einlösbar.
*/
async create({ days = 0, maxUses = 0 } = {}, teamId = this.teams.activeId) {
if (!teamId) throw new Error('Kein aktives Team')
const data: Record<string, unknown> = {
team: teamId,
max_uses: maxUses,
uses: 0,
created_by: auth.user?.id ?? '',
}
if (days > 0) {
const until = new Date()
until.setDate(until.getDate() + days)
data.expires = until.toISOString()
}
const invite = await api.collection('team_invites').create(data)
this.records = [invite, ...this.records]
return invite as TeamInvitesResponse
}
/**
* Link zurückziehen. Ohne Rückfrage: Ein Einladungslink ist nichts, was
* verloren gehen kann wer einen braucht, erzeugt den nächsten.
*/
async remove(id: string) {
await api.collection('team_invites').delete(id)
this.records = this.records.filter((r) => r.id !== id)
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
/**
* Die öffentliche Seite einer Einladung ohne Login, ohne Store, ohne Team.
* Sie läuft gegen die Routen aus backend/pb_hooks/team_invites.pb.js, nicht
* gegen eine Collection: Ein Nicht-Eingeloggter darf `team_invites` nicht
* einmal sehen.
*/
export type InviteTeam = {
id: string
collectionId: string
collectionName: string
name: string
logo: string
}
async function send(path: string, body?: unknown) {
const res = await fetch(`${api.baseURL.replace(/\/$/, '')}${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
// Für /join muss der Server wissen, wer beitritt.
...(api.authStore.token ? { Authorization: api.authStore.token } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data?.message || 'Die Einladung konnte nicht geöffnet werden.')
return data
}
export const publicInvite = {
/** Zu welchem Team gehört der Link? Wirft, wenn er nicht (mehr) gilt. */
async load(token: string): Promise<InviteTeam> {
const data = await send(`/api/team-invites/${encodeURIComponent(token)}`)
return data.team as InviteTeam
},
/** Konto anlegen und beitreten. */
async accept(
token: string,
data: { email: string; password: string; passwordConfirm: string; name: string },
) {
return await send(`/api/team-invites/${encodeURIComponent(token)}/accept`, data)
},
/** Als bereits angemeldeter Nutzer beitreten. */
async join(token: string, data: { name?: string } = {}) {
return await send(`/api/team-invites/${encodeURIComponent(token)}/join`, data)
},
}
export function setTeamInviteContext() {
const store = new TeamInviteStore()
setContext(KEY, store)
return store
}
export function getTeamInviteContext(): TeamInviteStore {
return getContext<TeamInviteStore>(KEY)
}

View file

@ -246,6 +246,19 @@ export class TeamStore {
return user return user
} }
/** 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() { destroy() {
if (this.unsubscribe) { if (this.unsubscribe) {
this.unsubscribe() this.unsubscribe()

View file

@ -75,11 +75,11 @@ export class TimeStore {
}).catch((e) => console.warn('times subscribe failed:', e)) }).catch((e) => console.warn('times subscribe failed:', e))
} }
async start(stageId: string, riderId: string) { async start(runId: string, riderId: string) {
const teamId = this.teams.activeId const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team') if (!teamId) throw new Error('Kein aktives Team')
return await api.collection('times').create({ return await api.collection('times').create({
stage: stageId, run: runId,
rider: riderId, rider: riderId,
team: teamId, team: teamId,
start: new Date().toISOString(), start: new Date().toISOString(),
@ -128,17 +128,17 @@ export class TimeStore {
}) })
} }
getByStage(stageId: string, now = Date.now()): TimeWithDuration[] { getByRun(runId: string, now = Date.now()): TimeWithDuration[] {
return this.scoped.filter((r) => r.stage === stageId).map((r) => enrich(r, now)) return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now))
} }
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] { getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now)) return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now))
} }
/** Laufende Zeiten einer Stage — die am längsten laufende zuerst. */ /** Laufende Zeiten eines Runs — der am längsten laufende zuerst. */
runningByStage(stageId: string, now = Date.now()): TimeWithDuration[] { runningByRun(runId: string, now = Date.now()): TimeWithDuration[] {
return this.getByStage(stageId, now) return this.getByRun(runId, now)
.filter((r) => r.status === 'active') .filter((r) => r.status === 'active')
.sort((a, b) => (a.start < b.start ? -1 : 1)) .sort((a, b) => (a.start < b.start ? -1 : 1))
} }
@ -147,21 +147,21 @@ export class TimeStore {
* Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf, * Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf,
* dürfen aber auch nicht unsichtbar werden. * dürfen aber auch nicht unsichtbar werden.
*/ */
unrankedByStage(stageId: string): TimeWithDuration[] { unrankedByRun(runId: string): TimeWithDuration[] {
return this.getByStage(stageId).filter( return this.getByRun(runId).filter(
(r) => r.status !== 'active' && r.status !== 'finished', (r) => r.status !== 'active' && r.status !== 'finished',
) )
} }
isRiderActive(stageId: string, riderId: string) { isRiderActive(runId: string, riderId: string) {
return this.scoped.some( return this.scoped.some(
(r) => r.stage === stageId && r.rider === riderId && r.status === 'active', (r) => r.run === runId && r.rider === riderId && r.status === 'active',
) )
} }
getActiveTime(stageId: string, riderId: string) { getActiveTime(runId: string, riderId: string) {
return this.scoped.find( return this.scoped.find(
(r) => r.stage === stageId && r.rider === riderId && r.status === 'active', (r) => r.run === runId && r.rider === riderId && r.status === 'active',
) )
} }
@ -170,8 +170,8 @@ export class TimeStore {
} }
/** /**
* Alle laufenden Zeiten des Teams, stage-übergreifend der am längsten * Alle laufenden Zeiten des Teams, run-übergreifend der am längsten
* laufende zuerst. `now` wie bei `getByStage`, damit die Anzeige mitzählt. * laufende zuerst. `now` wie bei `getByRun`, damit die Anzeige mitzählt.
*/ */
allRunning(now = Date.now()): TimeWithDuration[] { allRunning(now = Date.now()): TimeWithDuration[] {
return this.scoped return this.scoped
@ -185,15 +185,15 @@ export class TimeStore {
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now)) return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
} }
getLeaderboard(stageId: string): TimeWithDuration[] { getLeaderboard(runId: string): TimeWithDuration[] {
return this.getByStage(stageId) return this.getByRun(runId)
.filter((r) => r.status === 'finished' && r.duration !== undefined) .filter((r) => r.status === 'finished' && r.duration !== undefined)
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0)) .sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
} }
/** Platz eines Fahrers im Stage, 1-basiert; null ohne gewertete Zeit. */ /** Platz eines Fahrers im Run, 1-basiert; null ohne gewertete Zeit. */
placement(stageId: string, riderId: string): number | null { placement(runId: string, riderId: string): number | null {
const idx = this.getLeaderboard(stageId).findIndex((t) => t.rider === riderId) const idx = this.getLeaderboard(runId).findIndex((t) => t.rider === riderId)
return idx === -1 ? null : idx + 1 return idx === -1 ? null : idx + 1
} }

View file

@ -1,6 +1,3 @@
import { formatDistanceToNow } from 'date-fns'
import { de } from 'date-fns/locale'
/** /**
* Formatierung von Zeiten. Bewusst hier und nicht im times-Store: Auch * Formatierung von Zeiten. Bewusst hier und nicht im times-Store: Auch
* Komponenten ohne Store-Zugriff zeigen Dauern an, und die Darstellung soll * Komponenten ohne Store-Zugriff zeigen Dauern an, und die Darstellung soll
@ -26,39 +23,10 @@ export const TIME_STATUS_LABEL: Record<string, string> = {
dsq: 'DSQ', dsq: 'DSQ',
} }
/**
* Ein PocketBase-Zeitstempel als Date. Die kommen als
* `2026-09-12 08:30:00.000Z` mit Leerzeichen statt `T` die meisten Browser
* schlucken das, garantiert ist es nicht.
*/
function parseDate(value: string | undefined): Date | null {
if (!value) return null
const date = new Date(value.replace(' ', 'T'))
return Number.isNaN(date.getTime()) ? null : date
}
/** Zeitpunkt als `TT.MM.JJJJ, HH:MM`. */ /** Zeitpunkt als `TT.MM.JJJJ, HH:MM`. */
export function formatDateTime(iso: string | undefined): string { export function formatDateTime(iso: string | undefined): string {
const date = parseDate(iso) if (!iso) return '—'
if (!date) return '—' return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
return date.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
}
/**
* Zeitpunkt als Abstand zu jetzt: vor 3 Tagen".
*
* Für alles, woran die Frage wie frisch ist das?" hängt. Ein Datum muss man
* erst gegen den heutigen Tag rechnen, um zu wissen, ob eine Sperrung von
* gestern stammt oder vom letzten Sommer der Abstand sagt es sofort. Das
* genaue Datum gehört daneben in einen Tooltip, es geht dadurch nicht
* verloren.
*/
export function formatRelative(value: string | undefined): string {
const date = parseDate(value)
if (!date) return '—'
return formatDistanceToNow(date, { addSuffix: true, locale: de })
} }
/** Heutiges Datum als `JJJJ-MM-TT` in lokaler Zeit (nicht UTC). */ /** Heutiges Datum als `JJJJ-MM-TT` in lokaler Zeit (nicht UTC). */

View file

@ -10,8 +10,7 @@ export enum Collections {
EventSeries = "event_series", EventSeries = "event_series",
Events = "events", Events = "events",
Riders = "riders", Riders = "riders",
Stages = "stages", Runs = "runs",
TeamInvites = "team_invites",
Teams = "teams", Teams = "teams",
Times = "times", Times = "times",
TrailComments = "trail_comments", TrailComments = "trail_comments",
@ -117,14 +116,16 @@ export type EventsRecord = {
export type RidersRecord = { export type RidersRecord = {
avatar?: string avatar?: string
created?: IsoDateString created?: IsoDateString
firstname?: string
id: string id: string
lastname?: string
name?: string name?: string
team?: RecordIdString team?: RecordIdString
updated?: IsoDateString updated?: IsoDateString
user?: RecordIdString user?: RecordIdString
} }
export type StagesRecord = { export type RunsRecord = {
created?: IsoDateString created?: IsoDateString
description?: string description?: string
event?: RecordIdString event?: RecordIdString
@ -133,18 +134,6 @@ export type StagesRecord = {
updated?: IsoDateString updated?: IsoDateString
} }
export type TeamInvitesRecord = {
created?: IsoDateString
created_by?: RecordIdString
expires?: IsoDateString
id: string
max_uses?: number
team: RecordIdString
token: string
updated?: IsoDateString
uses?: number
}
export type TeamsRecord = { export type TeamsRecord = {
admins?: RecordIdString[] admins?: RecordIdString[]
created?: IsoDateString created?: IsoDateString
@ -169,7 +158,7 @@ export type TimesRecord = {
created?: IsoDateString created?: IsoDateString
id: string id: string
rider?: RecordIdString rider?: RecordIdString
stage?: RecordIdString run?: RecordIdString
start?: IsoDateString start?: IsoDateString
startedBy?: RecordIdString startedBy?: RecordIdString
status?: TimesStatusOptions status?: TimesStatusOptions
@ -278,8 +267,7 @@ export type EventParticipantsResponse<Texpand = unknown> = Required<EventPartici
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand> export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand> export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand> export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
export type StagesResponse<Texpand = unknown> = Required<StagesRecord> & BaseSystemFields<Texpand> export type RunsResponse<Texpand = unknown> = Required<RunsRecord> & BaseSystemFields<Texpand>
export type TeamInvitesResponse<Texpand = unknown> = Required<TeamInvitesRecord> & BaseSystemFields<Texpand>
export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand> export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand> export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand>
export type TrailCommentsResponse<Texpand = unknown> = Required<TrailCommentsRecord> & BaseSystemFields<Texpand> export type TrailCommentsResponse<Texpand = unknown> = Required<TrailCommentsRecord> & BaseSystemFields<Texpand>
@ -296,8 +284,7 @@ export type CollectionRecords = {
event_series: EventSeriesRecord event_series: EventSeriesRecord
events: EventsRecord events: EventsRecord
riders: RidersRecord riders: RidersRecord
stages: StagesRecord runs: RunsRecord
team_invites: TeamInvitesRecord
teams: TeamsRecord teams: TeamsRecord
times: TimesRecord times: TimesRecord
trail_comments: TrailCommentsRecord trail_comments: TrailCommentsRecord
@ -313,8 +300,7 @@ export type CollectionResponses = {
event_series: EventSeriesResponse event_series: EventSeriesResponse
events: EventsResponse events: EventsResponse
riders: RidersResponse riders: RidersResponse
stages: StagesResponse runs: RunsResponse
team_invites: TeamInvitesResponse
teams: TeamsResponse teams: TeamsResponse
times: TimesResponse times: TimesResponse
trail_comments: TrailCommentsResponse trail_comments: TrailCommentsResponse
@ -333,8 +319,7 @@ export type TypedPocketBase = PocketBase & {
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse> collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
collection(idOrName: 'events'): RecordService<EventsResponse> collection(idOrName: 'events'): RecordService<EventsResponse>
collection(idOrName: 'riders'): RecordService<RidersResponse> collection(idOrName: 'riders'): RecordService<RidersResponse>
collection(idOrName: 'stages'): RecordService<StagesResponse> collection(idOrName: 'runs'): RecordService<RunsResponse>
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
collection(idOrName: 'teams'): RecordService<TeamsResponse> collection(idOrName: 'teams'): RecordService<TeamsResponse>
collection(idOrName: 'times'): RecordService<TimesResponse> collection(idOrName: 'times'): RecordService<TimesResponse>
collection(idOrName: 'trail_comments'): RecordService<TrailCommentsResponse> collection(idOrName: 'trail_comments'): RecordService<TrailCommentsResponse>

View file

@ -2,7 +2,7 @@
import {auth} from '$lib/stores/pocketbase.svelte' import {auth} from '$lib/stores/pocketbase.svelte'
import {goto} from '$app/navigation' import {goto} from '$app/navigation'
import {onMount} from 'svelte' import {onMount} from 'svelte'
import Spinner from '$lib/components/Spinner.svelte' import {LoaderCircle} from 'lucide-svelte'
onMount(() => { onMount(() => {
goto(auth.user ? '/dashboard' : '/login', {replaceState: true}) goto(auth.user ? '/dashboard' : '/login', {replaceState: true})
@ -10,5 +10,5 @@
</script> </script>
<div class="min-h-screen flex items-center justify-center"> <div class="min-h-screen flex items-center justify-center">
<Spinner class="size-16"/> <LoaderCircle class="animate-spin" size="64"/>
</div> </div>

View file

@ -1,12 +1,10 @@
<script lang="ts"> <script lang="ts">
import { auth } from '$lib/stores/pocketbase.svelte' import { auth } from '$lib/stores/pocketbase.svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { page } from '$app/state' import { app, tooltip } from '$lib/stores/app.svelte'
import { tooltip } from '$lib/stores/app.svelte'
import { setTeamContext } from '$lib/stores/teams.svelte' import { setTeamContext } from '$lib/stores/teams.svelte'
import { setTeamInviteContext } from '$lib/stores/teamInvites.svelte'
import { setEventContext } from '$lib/stores/events.svelte' import { setEventContext } from '$lib/stores/events.svelte'
import { setStageContext } from '$lib/stores/stages.svelte' import { setRunContext } from '$lib/stores/runs.svelte'
import { setRiderContext } from '$lib/stores/riders.svelte' import { setRiderContext } from '$lib/stores/riders.svelte'
import { setTimeContext } from '$lib/stores/times.svelte' import { setTimeContext } from '$lib/stores/times.svelte'
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
@ -19,10 +17,10 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import Logo from '$lib/components/Logo.svelte' import Logo from '$lib/components/Logo.svelte'
import TeamMenu from '$lib/components/TeamMenu.svelte' import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte' import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Users } from 'lucide-svelte' import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte'
import { toggleMode, mode } from 'mode-watcher' import { toggleMode, mode } from 'mode-watcher'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
@ -31,9 +29,8 @@
// Teams MUSS vor den anderen Stores stehen, weil die anderen Stores // Teams MUSS vor den anderen Stores stehen, weil die anderen Stores
// getTeamContext() im Constructor aufrufen // getTeamContext() im Constructor aufrufen
const teams = setTeamContext() const teams = setTeamContext()
const invites = setTeamInviteContext()
const events = setEventContext() const events = setEventContext()
const stages = setStageContext() const runs = setRunContext()
const riders = setRiderContext() const riders = setRiderContext()
const times = setTimeContext() const times = setTimeContext()
// Nach events und riders: Der Store greift im Constructor auf beide zu. // Nach events und riders: Der Store greift im Constructor auf beide zu.
@ -56,14 +53,12 @@
await teams.load() await teams.load()
teams.subscribe() teams.subscribe()
await Promise.all([ await Promise.all([
events.load(), stages.load(), riders.load(), times.load(), participants.load(), series.load(), events.load(), runs.load(), riders.load(), times.load(), participants.load(), series.load(),
invites.load(),
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(), trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
trailComments.load(), trailComments.load(),
]) ])
invites.subscribe()
events.subscribe() events.subscribe()
stages.subscribe() runs.subscribe()
riders.subscribe() riders.subscribe()
times.subscribe() times.subscribe()
participants.subscribe() participants.subscribe()
@ -77,9 +72,8 @@
onDestroy(() => { onDestroy(() => {
teams.destroy() teams.destroy()
invites.destroy()
events.destroy() events.destroy()
stages.destroy() runs.destroy()
riders.destroy() riders.destroy()
times.destroy() times.destroy()
participants.destroy() participants.destroy()
@ -91,54 +85,25 @@
trailComments.destroy() trailComments.destroy()
}) })
// Ohne Rückfrage: Abmelden löscht nichts und ist mit einem Login wieder function handleLogout() {
// rückgängig gemacht — eine Bestätigung wäre nur ein zusätzlicher Klick. app.confirm.request({
async function handleLogout() { title: 'Abmelden?',
text: 'Möchten Sie sich wirklich abmelden?',
yes: async () => {
await auth.logout() await auth.logout()
app.confirm.close()
goto('/login') goto('/login')
},
no: () => app.confirm.close(),
})
} }
// Ein Menuepunkt sieht aus wie der andere, ob Link oder aufklappbar.
const navItemClass =
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors'
const navIdleClass = 'text-muted-foreground hover:text-foreground hover:bg-accent'
const navHoverClass = 'text-foreground bg-accent'
const navActiveClass = 'bg-primary text-primary-foreground hover:bg-primary/90'
/**
* Wo man gerade steht. „/dashboard" nur genau dort — sonst waere das
* Dashboard auf jeder Unterseite mit-aktiv, weil alle Pfade damit
* anfangen.
*/
function isActive(href: string) {
const path = page.url.pathname
return href === '/dashboard' ? path === '/dashboard' : path.startsWith(href)
}
/**
* Die vollstaendige Klassenliste eines Menuepunkts. Bewusst eine
* Funktion und kein zusaetzliches `class:`: `text-foreground` und
* `text-muted-foreground` sind dieselbe Utility-Gruppe — stuenden beide
* am Element, entschiede die Reihenfolge im Stylesheet und nicht die im
* Attribut.
*/
function navClass(href: string, hovered = false) {
if (isActive(href)) return `${navItemClass} ${navActiveClass}`
return `${navItemClass} ${hovered ? navHoverClass : navIdleClass}`
}
// Logo und Menuepunkt „Dashboard" fuehren an dieselbe Stelle, also
// reagieren sie gemeinsam — egal, welches von beiden man ueberfaehrt.
// Ueber CSS ginge das nicht: Der Menuepunkt steckt in der <nav>, das Logo
// daneben, und peer-hover reicht nur an Geschwister, nicht in sie hinein.
let dashHover = $state(false)
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides // Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
// passiert dort, wo es hingehört — an der Stage innerhalb ihres Events. // passiert dort, wo es hingehört — am Run innerhalb seines Events.
const nav = [ const nav = [
{ href: '/dashboard', icon: Home, label: 'Dashboard' }, { href: '/dashboard', icon: Home, label: 'Dashboard' },
{ href: '/dashboard/events', icon: Calendar, label: 'Events' }, { href: '/dashboard/events', icon: Calendar, label: 'Events' },
{ href: '/dashboard/team', icon: Users, label: 'Team', submenu: true }, { href: '/dashboard/riders', icon: Bike, label: 'Fahrer' },
{ href: '/dashboard/trails', icon: Route, label: 'Trails' }, { href: '/dashboard/trails', icon: Route, label: 'Trails' },
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' }, { href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
] ]
@ -156,54 +121,38 @@
aria-hidden="true" aria-hidden="true"
class="pointer-events-none fixed -bottom-24 -right-24 z-0 select-none opacity-[0.04] dark:opacity-[0.07]" class="pointer-events-none fixed -bottom-24 -right-24 z-0 select-none opacity-[0.04] dark:opacity-[0.07]"
> >
<Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" /> <Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" />
</div> </div>
<header class="relative z-10 border-b bg-card"> <header class="relative z-10 border-b bg-card">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex min-h-16 items-center justify-between gap-4 py-2"> <div class="flex min-h-16 items-center justify-between gap-4 py-2">
<!-- Links: Logo und Menü. Das negative -my-3 frisst das <!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
py-2 der Leiste auf und noch etwas mehr: Das Logo voller Groesse wuerde es die 64 px hohe Leiste
steht dadurch 72 px hoch in einer 64 px hohen Leiste, ueberragen und beschnitten wirken. -->
ragt oben und unten leicht darueber hinaus und wirkt
praesenter, ohne den Header hoeher zu machen. -->
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<a <Logo team={teams.active} class="h-11 w-11 shrink-0" />
href="/dashboard"
class="shrink-0 transition-transform relative duration-75 top-2"
class:scale-120={dashHover}
class:rotate-3={dashHover}
onmouseenter={() => (dashHover = true)}
onmouseleave={() => (dashHover = false)}
>
<Logo team={teams.active} class="size-[4.5rem] -my-3" />
</a>
<nav class="hidden md:flex items-center gap-2"> <nav class="hidden md:flex items-center gap-1">
{#each nav as item} {#each nav as item}
{@const Icon = item.icon} {@const Icon = item.icon}
{#if item.submenu}
<TeamMenu class={navClass(item.href)} />
{:else}
<a <a
href={item.href} href={item.href}
class={navClass(item.href, dashHover && item.href === '/dashboard')} class="inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
onmouseenter={() => item.href === '/dashboard' && (dashHover = true)}
onmouseleave={() => item.href === '/dashboard' && (dashHover = false)}
> >
<Icon class="h-4 w-4" /> <Icon class="h-4 w-4" />
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
{/if}
{/each} {/each}
</nav> </nav>
</div> </div>
<!-- Rechts: wer man ist, und was man mit sich tun kann. <!-- Rechts eine Spalte: Name samt Design und Logout in einer
Das Team steht jetzt im Menue links. --> Zeile, das Team-Dropdown darunter. -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="hidden md:flex min-w-0 items-center gap-3"> <!-- Wer man ist, steht links; was man tun kann, rechts. -->
<div class="hidden md:flex min-w-56 flex-col gap-1">
<div class="flex items-center justify-between gap-3">
<a <a
href="/dashboard/settings" href="/dashboard/settings"
class="flex min-w-0 items-center gap-2" class="flex min-w-0 items-center gap-2"
@ -241,6 +190,11 @@
</span> </span>
</div> </div>
<div class="flex">
<TeamSwitcher />
</div>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -263,21 +217,14 @@
<nav class="px-2 py-2 space-y-1"> <nav class="px-2 py-2 space-y-1">
{#each nav as item} {#each nav as item}
{@const Icon = item.icon} {@const Icon = item.icon}
{#if item.submenu}
<TeamMenu
class="w-full {navClass(item.href)}"
onnavigate={() => (isMobileMenuOpen = false)}
/>
{:else}
<a <a
href={item.href} href={item.href}
onclick={() => (isMobileMenuOpen = false)} onclick={() => (isMobileMenuOpen = false)}
class="w-full {navClass(item.href)}" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent"
> >
<Icon class="h-4 w-4" /> <Icon class="h-4 w-4" />
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
{/if}
{/each} {/each}
<Separator class="my-2" /> <Separator class="my-2" />
<div class="flex items-center justify-between gap-2 px-3 py-2"> <div class="flex items-center justify-between gap-2 px-3 py-2">
@ -288,6 +235,7 @@
{auth.user.name || auth.user.email} {auth.user.name || auth.user.email}
</span> </span>
</span> </span>
<TeamSwitcher />
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln"> <Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln">

View file

@ -4,7 +4,7 @@
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { getRiderContext } from '$lib/stores/riders.svelte' import { getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { getStageContext } from '$lib/stores/stages.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -13,7 +13,7 @@
const teams = getTeamContext() const teams = getTeamContext()
const events = getEventContext() const events = getEventContext()
const stages = getStageContext() const runs = getRunContext()
const riders = getRiderContext() const riders = getRiderContext()
const times = getTimeContext() const times = getTimeContext()
@ -42,7 +42,7 @@
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
</p> </p>
{:else} {:else}
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/team" class="underline">Team verwalten</a>.</p> <p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/settings/team" class="underline">Team verwalten</a>.</p>
{/if} {/if}
</div> </div>
@ -97,7 +97,7 @@
{:else} {:else}
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
{#each upNext as event} {#each upNext as event}
{@const eventStages = stages.getByEvent(event.id).length} {@const eventRuns = runs.getByEvent(event.id).length}
<Card class="hover:border-primary transition-colors"> <Card class="hover:border-primary transition-colors">
<a href="/dashboard/events/{event.id}" class="block"> <a href="/dashboard/events/{event.id}" class="block">
<CardHeader> <CardHeader>
@ -117,7 +117,7 @@
{/if} {/if}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p class="text-sm text-muted-foreground">{eventStages} Stage{eventStages === 1 ? '' : 's'}</p> <p class="text-sm text-muted-foreground">{eventRuns} Run{eventRuns === 1 ? '' : 's'}</p>
</CardContent> </CardContent>
</a> </a>
</Card> </Card>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { getStageContext } from '$lib/stores/stages.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { import {
getEventSeriesContext, getEventSeriesContext,
@ -22,7 +22,7 @@
import type { EventsResponse } from '$lib/types' import type { EventsResponse } from '$lib/types'
const events = getEventContext() const events = getEventContext()
const stages = getStageContext() const runs = getRunContext()
const participants = getEventParticipantContext() const participants = getEventParticipantContext()
const series = getEventSeriesContext() const series = getEventSeriesContext()
@ -244,7 +244,7 @@
dayOf(s.until) || undefined, dayOf(s.until) || undefined,
)} )}
<Card> <Card>
<CardContent class="space-y-2"> <CardContent class="py-4 space-y-2">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<p class="font-medium truncate">{s.name}</p> <p class="font-medium truncate">{s.name}</p>
@ -301,7 +301,7 @@
<h2 class="text-lg font-semibold">{group.title} <span class="text-muted-foreground font-normal">({group.items.length})</span></h2> <h2 class="text-lg font-semibold">{group.title} <span class="text-muted-foreground font-normal">({group.items.length})</span></h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each group.items as event} {#each group.items as event}
{@const stageCount = stages.getByEvent(event.id).length} {@const runCount = runs.getByEvent(event.id).length}
<Card class="hover:border-primary transition-colors"> <Card class="hover:border-primary transition-colors">
<a href="/dashboard/events/{event.id}" class="block"> <a href="/dashboard/events/{event.id}" class="block">
<CardHeader> <CardHeader>
@ -328,7 +328,7 @@
{#if event.timing} {#if event.timing}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<Timer class="size-3.5" /> <Timer class="size-3.5" />
{stageCount} Stage{stageCount === 1 ? '' : 's'} {runCount} Run{runCount === 1 ? '' : 's'}
</span> </span>
{/if} {/if}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
@ -341,7 +341,7 @@
</div> </div>
</CardContent> </CardContent>
</a> </a>
<div class="px-4 flex items-center justify-end gap-1 border-t pt-3"> <div class="px-6 pb-4 flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} tooltip="Bearbeiten"> <Button variant="ghost" size="icon" onclick={() => openEdit(event)} tooltip="Bearbeiten">
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>
@ -416,7 +416,7 @@
mit Zeitnahme mit Zeitnahme
</label> </label>
<p class="text-xs text-muted-foreground -mt-2"> <p class="text-xs text-muted-foreground -mt-2">
Blendet Stages und Zeitnahme ein. Für Stammtisch oder Feierabendrunde Blendet Runs und Zeitnahme ein. Für Stammtisch oder Feierabendrunde
unnötig. unnötig.
</p> </p>

View file

@ -2,8 +2,8 @@
import { page } from '$app/state' import { page } from '$app/state'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { getStageContext } from '$lib/stores/stages.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
import { import {
@ -36,10 +36,10 @@
Check, Check,
X, X,
} from 'lucide-svelte' } from 'lucide-svelte'
import type { EventParticipantsResponse, StagesResponse } from '$lib/types' import type { EventParticipantsResponse, RunsResponse } from '$lib/types'
const events = getEventContext() const events = getEventContext()
const stages = getStageContext() const runs = getRunContext()
const riders = getRiderContext() const riders = getRiderContext()
const times = getTimeContext() const times = getTimeContext()
const teams = getTeamContext() const teams = getTeamContext()
@ -47,7 +47,7 @@
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined) const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const eventParticipants = $derived(event ? participants.byEvent(event.id) : []) const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
const eventStages = $derived(event ? stages.getByEvent(event.id) : []) const eventRuns = $derived(event ? runs.getByEvent(event.id) : [])
/** /**
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung — * Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
@ -65,14 +65,14 @@
!!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen', !!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen',
) )
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Stage bzw. Fahrer. // Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Run bzw. Fahrer.
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist. // Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
function riderPath(riderId: string) { function riderPath(riderId: string) {
return `/dashboard/events/${event?.id}/riders/${riderId}` return `/dashboard/events/${event?.id}/riders/${riderId}`
} }
function stagePath(stageId: string) { function runPath(runId: string) {
return `/dashboard/events/${event?.id}/stages/${stageId}` return `/dashboard/events/${event?.id}/runs/${runId}`
} }
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen. // Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
@ -162,30 +162,30 @@
} }
} }
// Stage-Dialog // Run-Dialog
let stageDialog = $state(false) let runDialog = $state(false)
let stageEdit = $state<StagesResponse | null>(null) let runEdit = $state<RunsResponse | null>(null)
let stageForm = $state({ description: '' }) let runForm = $state({ description: '' })
function openStageCreate() { function openRunCreate() {
stageEdit = null runEdit = null
stageForm = { description: '' } runForm = { description: '' }
stageDialog = true runDialog = true
} }
function openStageEdit(r: StagesResponse) { function openRunEdit(r: RunsResponse) {
stageEdit = r runEdit = r
stageForm = { description: r.description ?? '' } runForm = { description: r.description ?? '' }
stageDialog = true runDialog = true
} }
async function saveStage() { async function saveRun() {
if (!event) return if (!event) return
if (!stageForm.description.trim()) return alert('Bezeichnung ist Pflicht') if (!runForm.description.trim()) return alert('Bezeichnung ist Pflicht')
try { try {
if (stageEdit) await stages.edit(stageEdit.id, stageForm) if (runEdit) await runs.edit(runEdit.id, runForm)
else await stages.create({ ...stageForm, event: event.id }) else await runs.create({ ...runForm, event: event.id })
stageDialog = false runDialog = false
} catch (e: any) { } catch (e: any) {
alert(`Fehler: ${e.message}`) alert(`Fehler: ${e.message}`)
} }
@ -236,7 +236,7 @@
<Button <Button
onclick={() => (timeDialog = true)} onclick={() => (timeDialog = true)}
disabled={eventParticipants.length === 0 || eventStages.length === 0} disabled={eventParticipants.length === 0 || eventRuns.length === 0}
> >
<Timer class="h-4 w-4 mr-2" /> <Timer class="h-4 w-4 mr-2" />
Zeit erfassen Zeit erfassen
@ -283,7 +283,7 @@
{#if canManage && available.length === 0 && riders.scoped.length === 0} {#if canManage && available.length === 0 && riders.scoped.length === 0}
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Das Team hat noch keine Fahrer. Das Team hat noch keine Fahrer.
<a href="/dashboard/team" class="underline">Jetzt anlegen</a> <a href="/dashboard/settings/riders" class="underline">Jetzt anlegen</a>
</p> </p>
{/if} {/if}
@ -322,8 +322,8 @@
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. --> <!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
<a href={riderPath(p.rider)} class="hover:underline"> <a href={riderPath(p.rider)} class="hover:underline">
{#if rider && riderName(rider)} {#if rider && fullName(rider)}
{riderName(rider)} {fullName(rider)}
{:else} {:else}
<span class="text-muted-foreground italic">— ohne Name —</span> <span class="text-muted-foreground italic">— ohne Name —</span>
{/if} {/if}
@ -342,7 +342,7 @@
</Table.Cell> </Table.Cell>
{#if event.timing} {#if event.timing}
<Table.Cell class="text-muted-foreground text-sm"> <Table.Cell class="text-muted-foreground text-sm">
{done} von {eventStages.length} Stages {done} von {eventRuns.length} Runs
</Table.Cell> </Table.Cell>
{/if} {/if}
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}> <Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
@ -411,34 +411,34 @@
</Card> </Card>
</section> </section>
<!-- Stages — nur bei Events mit Zeitnahme --> <!-- Runs — nur bei Events mit Zeitnahme -->
{#if event.timing} {#if event.timing}
<section class="space-y-4"> <section class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-semibold flex items-center gap-2"> <h2 class="text-xl font-semibold flex items-center gap-2">
<Timer class="h-5 w-5" /> <Timer class="h-5 w-5" />
Stages Runs
<span class="text-muted-foreground font-normal text-base">({eventStages.length})</span> <span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
</h2> </h2>
<p class="text-sm text-muted-foreground">Klick auf eine Stage öffnet die Zeitnahme</p> <p class="text-sm text-muted-foreground">Klick auf einen Run öffnet die Zeitnahme</p>
</div> </div>
<Button onclick={openStageCreate}> <Button onclick={openRunCreate}>
<Plus class="h-4 w-4 mr-2" /> <Plus class="h-4 w-4 mr-2" />
Stage anlegen Run anlegen
</Button> </Button>
</div> </div>
<Card> <Card>
<CardContent class="p-0"> <CardContent class="p-0">
{#if eventStages.length === 0} {#if eventRuns.length === 0}
<div class="py-12 text-center"> <div class="py-12 text-center">
<Timer class="h-10 w-10 text-muted-foreground mx-auto mb-3" /> <Timer class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch keine Stages</p> <p class="font-medium">Noch keine Runs</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Lege den ersten Lauf an.</p> <p class="text-sm text-muted-foreground mt-1 mb-4">Lege den ersten Lauf an.</p>
<Button onclick={openStageCreate}> <Button onclick={openRunCreate}>
<Plus class="h-4 w-4 mr-2" /> <Plus class="h-4 w-4 mr-2" />
Erste Stage anlegen Ersten Run anlegen
</Button> </Button>
</div> </div>
{:else} {:else}
@ -451,13 +451,13 @@
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each eventStages as stage (stage.id)} {#each eventRuns as run (run.id)}
{@const stageTimes = times.getByStage(stage.id)} {@const runTimes = times.getByRun(run.id)}
{@const active = stageTimes.filter((t) => t.status === 'active').length} {@const active = runTimes.filter((t) => t.status === 'active').length}
{@const ranked = stageTimes.filter((t) => t.status === 'finished').length} {@const ranked = runTimes.filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(stagePath(stage.id))}> <Table.Row class="cursor-pointer" onclick={() => goto(runPath(run.id))}>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<a href={stagePath(stage.id)} class="hover:underline">{stage.description}</a> <a href={runPath(run.id)} class="hover:underline">{run.description}</a>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{#if active > 0} {#if active > 0}
@ -466,10 +466,10 @@
<span class="text-muted-foreground text-sm">{ranked} gewertet</span> <span class="text-muted-foreground text-sm">{ranked} gewertet</span>
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}> <Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onclick={() => openStageEdit(stage)}> <Button variant="ghost" size="icon" onclick={() => openRunEdit(run)}>
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" onclick={() => stages.remove(stage)}> <Button variant="ghost" size="icon" onclick={() => runs.remove(run)}>
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
</Button> </Button>
</Table.Cell> </Table.Cell>
@ -507,12 +507,12 @@
<Select.Root type="single" bind:value={form.rider}> <Select.Root type="single" bind:value={form.rider}>
<Select.Trigger class="w-full"> <Select.Trigger class="w-full">
{form.rider {form.rider
? riderName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…' ? fullName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
: 'Fahrer wählen…'} : 'Fahrer wählen…'}
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
{#each available as r (r.id)} {#each available as r (r.id)}
<Select.Item value={r.id}>{riderName(r) || '— ohne Name —'}</Select.Item> <Select.Item value={r.id}>{fullName(r) || '— ohne Name —'}</Select.Item>
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
@ -539,29 +539,29 @@
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<!-- Stage-Dialog --> <!-- Run-Dialog -->
<Dialog.Root bind:open={stageDialog}> <Dialog.Root bind:open={runDialog}>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{stageEdit ? 'Stage bearbeiten' : 'Neue Stage'}</Dialog.Title> <Dialog.Title>{runEdit ? 'Run bearbeiten' : 'Neuer Run'}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
Stage für „{event?.name}" Run für „{event?.name}"
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form <form
onsubmit={(e) => { onsubmit={(e) => {
e.preventDefault() e.preventDefault()
saveStage() saveRun()
}} }}
class="space-y-4" class="space-y-4"
> >
<div class="space-y-2"> <div class="space-y-2">
<Label for="stage-desc">Bezeichnung *</Label> <Label for="run-desc">Bezeichnung *</Label>
<Input id="stage-desc" bind:value={stageForm.description} placeholder="Stage 1 Downhill" required /> <Input id="run-desc" bind:value={runForm.description} placeholder="Stage 1 Downhill" required />
</div> </div>
<Dialog.Footer> <Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (stageDialog = false)}>Abbrechen</Button> <Button type="button" variant="ghost" onclick={() => (runDialog = false)}>Abbrechen</Button>
<Button type="submit">{stageEdit ? 'Speichern' : 'Anlegen'}</Button> <Button type="submit">{runEdit ? 'Speichern' : 'Anlegen'}</Button>
</Dialog.Footer> </Dialog.Footer>
</form> </form>
</Dialog.Content> </Dialog.Content>

View file

@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
/** /**
* Fahrerübersicht: alle Stages des Events aus Sicht eines Fahrers. Gezeigt * Fahrerübersicht: alle Runs des Events aus Sicht eines Fahrers. Gezeigt
* wird je Stage seine Zeit, sein Platz und ob gerade gemessen wird — die * wird je Run seine Zeit, sein Platz und ob gerade gemessen wird — die
* Zeitnahme selbst passiert auf der Stage-Seite, dorthin führt jede Zeile. * Zeitnahme selbst passiert auf der Run-Seite, dorthin führt jede Zeile.
*/ */
import { page } from '$app/state' import { page } from '$app/state'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { getStageContext } from '$lib/stores/stages.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte' import { ticker } from '$lib/stores/ticker.svelte'
@ -22,7 +22,7 @@
const events = getEventContext() const events = getEventContext()
const stages = getStageContext() const runs = getRunContext()
const riders = getRiderContext() const riders = getRiderContext()
const times = getTimeContext() const times = getTimeContext()
const participants = getEventParticipantContext() const participants = getEventParticipantContext()
@ -37,40 +37,40 @@
const valid = $derived(!!event && !!rider && !!entry) const valid = $derived(!!event && !!rider && !!entry)
/** /**
* Eine Zeile je Stage des Events — auch für Stages ohne Zeit, damit sichtbar * Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
* bleibt, was dem Fahrer noch fehlt. * bleibt, was dem Fahrer noch fehlt.
*/ */
const rows = $derived.by(() => { const rows = $derived.by(() => {
if (!valid || !event || !rider) return [] if (!valid || !event || !rider) return []
// clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste // clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste
// aktuell; bei einer Handvoll Stages ist das billig. // aktuell; bei einer Handvoll Runs ist das billig.
const own = times.getByRider(rider.id, clock.now) const own = times.getByRider(rider.id, clock.now)
return stages.getByEvent(event.id).map((stage) => { return runs.getByEvent(event.id).map((run) => {
const forStage = own.filter((t) => t.stage === stage.id) const forRun = own.filter((t) => t.run === run.id)
const active = forStage.find((t) => t.status === 'active') const active = forRun.find((t) => t.status === 'active')
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit. // Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
const best = forStage const best = forRun
.filter((t) => t.status === 'finished') .filter((t) => t.status === 'finished')
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0] .sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
return { return {
stage, run,
active, active,
best, best,
others: forStage.filter((t) => t.status !== 'active' && t.status !== 'finished'), others: forRun.filter((t) => t.status !== 'active' && t.status !== 'finished'),
placement: best ? times.placement(stage.id, rider.id) : null, placement: best ? times.placement(run.id, rider.id) : null,
} }
}) })
}) })
// Start und Stopp laufen ausschließlich über den Zeit-Dialog. // Start und Stopp laufen ausschließlich über den Zeit-Dialog.
let timeDialog = $state(false) let timeDialog = $state(false)
let dialogStage = $state<string | null>(null) let dialogRun = $state<string | null>(null)
function openTimeDialog(stageId: string | null = null) { function openTimeDialog(runId: string | null = null) {
dialogStage = stageId dialogRun = runId
timeDialog = true timeDialog = true
} }
@ -85,7 +85,7 @@
}) })
</script> </script>
<svelte:head><title>{rider ? riderName(rider) : 'Fahrer'} {event?.name ?? 'Event'}</title></svelte:head> <svelte:head><title>{rider ? fullName(rider) : 'Fahrer'} {event?.name ?? 'Event'}</title></svelte:head>
{#if !valid} {#if !valid}
<Card> <Card>
@ -117,7 +117,7 @@
<Badge variant="outline" class="text-lg px-3 py-1">{entry.number}</Badge> <Badge variant="outline" class="text-lg px-3 py-1">{entry.number}</Badge>
{/if} {/if}
<h1 class="text-3xl font-bold tracking-tight"> <h1 class="text-3xl font-bold tracking-tight">
{riderName(rider) || 'Ohne Name'} {fullName(rider) || 'Ohne Name'}
</h1> </h1>
</div> </div>
</div> </div>
@ -131,7 +131,7 @@
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
<Card> <Card>
<CardHeader class="pb-2"> <CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Stages</CardTitle> <CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Runs</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div> <div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
@ -163,19 +163,19 @@
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
<Timer class="h-4 w-4 text-primary" /> <Timer class="h-4 w-4 text-primary" />
Stages ({rows.length}) Runs ({rows.length})
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent class="p-0"> <CardContent class="p-0">
{#if rows.length === 0} {#if rows.length === 0}
<p class="px-6 py-8 text-sm text-center text-muted-foreground"> <p class="px-6 py-8 text-sm text-center text-muted-foreground">
Für dieses Event gibt es noch keine Stages. Für dieses Event gibt es noch keine Runs.
</p> </p>
{:else} {:else}
<Table.Root> <Table.Root>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Stage</Table.Head> <Table.Head>Run</Table.Head>
<Table.Head class="w-24">Status</Table.Head> <Table.Head class="w-24">Status</Table.Head>
<Table.Head class="text-right w-28">Zeit</Table.Head> <Table.Head class="text-right w-28">Zeit</Table.Head>
<Table.Head class="text-right w-20">Platz</Table.Head> <Table.Head class="text-right w-20">Platz</Table.Head>
@ -183,17 +183,17 @@
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each rows as row (row.stage.id)} {#each rows as row (row.run.id)}
<Table.Row <Table.Row
class="cursor-pointer" class="cursor-pointer"
onclick={() => goto(`/dashboard/events/${event.id}/stages/${row.stage.id}`)} onclick={() => goto(`/dashboard/events/${event.id}/runs/${row.run.id}`)}
> >
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<a <a
href="/dashboard/events/{event.id}/stages/{row.stage.id}" href="/dashboard/events/{event.id}/runs/{row.run.id}"
class="hover:underline" class="hover:underline"
> >
{row.stage.description || 'Stage'} {row.run.description || 'Run'}
</a> </a>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
@ -244,5 +244,5 @@
bind:open={timeDialog} bind:open={timeDialog}
eventId={event?.id ?? ''} eventId={event?.id ?? ''}
riderId={rider?.id ?? null} riderId={rider?.id ?? null}
stageId={dialogStage} runId={dialogRun}
/> />

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
/** /**
* Stage-Übersicht: laufende und gewertete Zeiten in einer Tabelle, die * Run-Übersicht: laufende und gewertete Zeiten in einer Tabelle, die
* laufenden zählen in Echtzeit mit. Die Tabelle zeigt nur an — gestartet * laufenden zählen in Echtzeit mit. Die Tabelle zeigt nur an — gestartet
* und gestoppt wird ausschließlich im Zeit-Dialog. Auf dem Handy an der * und gestoppt wird ausschließlich im Zeit-Dialog. Auf dem Handy an der
* Strecke ist ein versehentlicher Treffer sonst schnell passiert, und * Strecke ist ein versehentlicher Treffer sonst schnell passiert, und
@ -8,8 +8,8 @@
*/ */
import { page } from '$app/state' import { page } from '$app/state'
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { getStageContext } from '$lib/stores/stages.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte' import { ticker } from '$lib/stores/ticker.svelte'
@ -22,7 +22,7 @@
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte' import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
const events = getEventContext() const events = getEventContext()
const stages = getStageContext() const runs = getRunContext()
const riders = getRiderContext() const riders = getRiderContext()
const times = getTimeContext() const times = getTimeContext()
const participants = getEventParticipantContext() const participants = getEventParticipantContext()
@ -30,17 +30,17 @@
const clock = ticker() const clock = ticker()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined) const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const stage = $derived(page.params.stageId ? stages.getById(page.params.stageId) : undefined) const run = $derived(page.params.runId ? runs.getById(page.params.runId) : undefined)
// Ein Stage gehört genau zu einem Event; passt die URL nicht zusammen, ist // Ein Run gehört genau zu einem Event; passt die URL nicht zusammen, ist
// sie von Hand zusammengebaut oder veraltet. // sie von Hand zusammengebaut oder veraltet.
const valid = $derived(!!event && !!stage && stage.event === event.id) const valid = $derived(!!event && !!run && run.event === event.id)
// Am Start steht, wer beim Event zugesagt hat. // Am Start steht, wer beim Event zugesagt hat.
const starters = $derived(event ? participants.confirmedByEvent(event.id) : []) const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
const running = $derived(valid && stage ? times.runningByStage(stage.id, clock.now) : []) const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
const leaderboard = $derived(valid && stage ? times.getLeaderboard(stage.id) : []) const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
const unranked = $derived(valid && stage ? times.unrankedByStage(stage.id) : []) const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
/** /**
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist, * Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
@ -61,7 +61,7 @@
} }
// --- Zeitnahme -------------------------------------------------------- // --- Zeitnahme --------------------------------------------------------
// Start und Stopp laufen ausschließlich über TimeDialog; die Stage ist // Start und Stopp laufen ausschließlich über TimeDialog; der Run ist
// dabei vorbelegt, den Fahrer wählt man dort. // dabei vorbelegt, den Fahrer wählt man dort.
let timeDialog = $state(false) let timeDialog = $state(false)
@ -75,14 +75,14 @@
rows.push([ rows.push([
String(i + 1), String(i + 1),
event ? participants.numberOf(event.id, t.rider) : '', event ? participants.numberOf(event.id, t.rider) : '',
r ? riderName(r) : '', r ? fullName(r) : '',
t.formattedTime ?? '', t.formattedTime ?? '',
String(t.correction ?? 0), String(t.correction ?? 0),
]) ])
}) })
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n') const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
const safeName = (stage?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-') const safeName = (run?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
@ -93,13 +93,13 @@
} }
</script> </script>
<svelte:head><title>{stage?.description ?? 'Stage'} {event?.name ?? 'Event'}</title></svelte:head> <svelte:head><title>{run?.description ?? 'Run'} {event?.name ?? 'Event'}</title></svelte:head>
{#if !valid} {#if !valid}
<Card> <Card>
<CardContent class="py-16 text-center"> <CardContent class="py-16 text-center">
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Stage nicht gefunden</p> <p class="text-lg font-medium">Run nicht gefunden</p>
<p class="text-sm text-muted-foreground mt-1"> <p class="text-sm text-muted-foreground mt-1">
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht. Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
</p> </p>
@ -109,7 +109,7 @@
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{:else if event && stage} {:else if event && run}
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div> <div>
@ -120,7 +120,7 @@
<ArrowLeft class="h-3 w-3" /> <ArrowLeft class="h-3 w-3" />
{event.name || 'Event'} {event.name || 'Event'}
</a> </a>
<h1 class="text-3xl font-bold tracking-tight">{stage.description || 'Stage'}</h1> <h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
<p class="mt-1 text-muted-foreground"> <p class="mt-1 text-muted-foreground">
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start {running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start
</p> </p>
@ -195,7 +195,7 @@
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
{#if r} {#if r}
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a> <a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
{:else} {:else}
{/if} {/if}
@ -233,7 +233,7 @@
</Badge> </Badge>
<span class="truncate"> <span class="truncate">
{#if r} {#if r}
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a> <a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
{:else} {:else}
{/if} {/if}
@ -249,4 +249,4 @@
</div> </div>
{/if} {/if}
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} stageId={stage?.id ?? null} /> <TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} runId={run?.id ?? null} />

View file

@ -0,0 +1,134 @@
<script lang="ts">
/**
* Fahrerverzeichnis des Teams — für alle sichtbar, für niemanden hier
* änderbar. Anlegen, Bearbeiten und Kontoverknüpfung liegen in den
* Einstellungen und damit bei der Teamleitung; serverseitig setzen das
* die create/update/deleteRule der Collection durch.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import * as Table from '@/components/ui/table'
import { Search, Settings, UserCheck, Users } from 'lucide-svelte'
const teams = getTeamContext()
const riders = getRiderContext()
const times = getTimeContext()
const participants = getEventParticipantContext()
let query = $state('')
const roster = $derived.by(() => {
const q = query.toLowerCase().trim()
if (!q) return riders.scoped
return riders.scoped.filter((r) => fullName(r).toLowerCase().includes(q))
})
$effect(() => {
teams.loadMembers()
})
/**
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen am Run und
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
*/
function riderPath(riderId: string) {
const latest = participants.byRider(riderId).at(-1)
return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null
}
</script>
<svelte:head><title>Fahrer</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold tracking-tight">Fahrer</h1>
<p class="mt-1 text-muted-foreground">
{riders.scoped.length} im Team {teams.active?.name ?? ''}
</p>
</div>
{#if riders.canManage}
<Button variant="outline" href="/dashboard/settings/riders">
<Settings class="size-4 mr-2" />
Verwalten
</Button>
{/if}
</div>
<div class="relative max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input bind:value={query} placeholder="Name…" class="pl-9" />
</div>
{#if riders.scoped.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch keine Fahrer</p>
<p class="text-sm text-muted-foreground mt-1">
{#if riders.canManage}
Leg sie in den <a href="/dashboard/settings/riders" class="underline">Einstellungen</a> an.
{:else}
Die Teamleitung legt Fahrer in den Einstellungen an.
{/if}
</p>
</CardContent>
</Card>
{:else if roster.length === 0}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">Keine Treffer.</CardContent>
</Card>
{:else}
<Card>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head class="w-28">Events</Table.Head>
<Table.Head class="text-right w-24">Zeiten</Table.Head>
<Table.Head class="w-48">Konto</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each roster as r (r.id)}
{@const path = riderPath(r.id)}
<Table.Row>
<Table.Cell class="font-medium">
{#if path}
<a href={path} class="hover:underline">{fullName(r) || '— ohne Name —'}</a>
{:else}
{fullName(r) || '— ohne Name —'}
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{participants.byRider(r.id).length}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground text-sm">
{times.getByRider(r.id).length}
</Table.Cell>
<Table.Cell>
{#if r.user}
<Badge variant="secondary" class="gap-1">
<UserCheck class="size-3" />
{teams.userLabel(r.user)}
</Badge>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
{/if}
</div>

View file

@ -2,19 +2,27 @@
/** /**
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich. * Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
* *
* Hier steht nur noch, was einen selbst betrifft — das eigene Profil und * „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die
* die Flag-Typen. Team und Kader sind unter „Team" zu Hause; sie sind * Menschen: Jedes Mitglied ist ein Fahrer, ein Konto hat aber nicht jeder.
* keine Einstellung, sondern der Gegenstand der Anwendung. * Beides an einer Stelle, weil es sonst zwei Wahrheiten über dieselbe
* Person gäbe. Der Reiter erscheint nur für die Teamleitung.
*/ */
import { page } from '$app/state' import { page } from '$app/state'
import { Flag, User } from 'lucide-svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
import { Flag, User, Users, Bike } from 'lucide-svelte'
let { children } = $props() let { children } = $props()
const tabs = [ const teams = getTeamContext()
const tabs = $derived([
{ href: '/dashboard/settings', label: 'Profil', icon: User }, { href: '/dashboard/settings', label: 'Profil', icon: User },
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
...(teams.isAdmin()
? [{ href: '/dashboard/settings/riders', label: 'Fahrer & Konten', icon: Bike }]
: []),
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag }, { href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
] ])
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter // Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
// auf jedem Unterpfad mit aktiv. // auf jedem Unterpfad mit aktiv.

View file

@ -5,40 +5,16 @@
* also niemand für jemand anderen ändern. * also niemand für jemand anderen ändern.
*/ */
import { api, auth } from '$lib/stores/pocketbase.svelte' import { api, auth } from '$lib/stores/pocketbase.svelte'
import { app } from '$lib/stores/app.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { goto } from '$app/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Camera, KeyRound, Trash2, User } from 'lucide-svelte' import { KeyRound, User } from 'lucide-svelte'
let name = $state(auth.user?.name ?? '') let name = $state(auth.user?.name ?? '')
let avatarFile = $state<File | null>(null) let avatarFile = $state<File | null>(null)
/**
* Vorschau des gewaehlten Bildes. Ohne sie bliebe die Wahl unsichtbar —
* das Dateifeld ist versteckt, es gibt also keinen Dateinamen zu lesen.
* Die Object-URL wird beim naechsten Wechsel wieder freigegeben, sonst
* haelt der Browser jedes einmal angesehene Bild bis zum Seitenwechsel
* fest.
*/
let avatarPreview = $state<string | null>(null)
$effect(() => {
const file = avatarFile
if (!file) {
avatarPreview = null
return
}
const url = URL.createObjectURL(file)
avatarPreview = url
return () => URL.revokeObjectURL(url)
})
let savingProfile = $state(false) let savingProfile = $state(false)
let profileMessage = $state<string | null>(null) let profileMessage = $state<string | null>(null)
let profileError = $state<string | null>(null) let profileError = $state<string | null>(null)
@ -74,56 +50,6 @@
} }
} }
// --- Eigenes Konto löschen -------------------------------------------
//
// `users.deleteRule = id = @request.auth.id` erlaubt genau das und sonst
// nichts: Niemand löscht ein fremdes Konto, auch die Teamleitung nicht.
//
// Was dabei bleibt: die Fahrer, die an diesem Konto hingen. Deren
// Verknüpfung ist ohne Kaskade angelegt (riders.user, cascadeDelete
// false) — der Fahrer bleibt also mit Namen, Zeiten und Teilnahmen im
// Kader stehen und hat nur kein Login mehr. Das ist die Absicht:
// Ergebnisse gehören dem Team, nicht dem Konto.
//
// Wer ein Team besitzt, kommt hier nicht durch. `teams.owner` ist ein
// Pflichtfeld ohne Kaskade; PocketBase müsste es beim Löschen leeren und
// bräche dabei den Datensatz. Der Versuch endet in einem Fehler, den
// niemand deuten kann — deshalb steht die Bedingung hier vorne und im
// Klartext.
const teams = getTeamContext()
const ownedTeams = $derived(teams.records.filter((t) => t.owner === auth.user?.id))
let deletingAccount = $state(false)
let deleteError = $state<string | null>(null)
function deleteAccount() {
if (!auth.user || ownedTeams.length > 0) return
app.confirm.request({
title: 'Konto löschen?',
text:
'Dein Konto wird endgültig gelöscht und du wirst abgemeldet. ' +
'Dein Fahrer bleibt mit allen Zeiten und Teilnahmen im Kader stehen — ' +
'nur anmelden kannst du dich nicht mehr. Über einen neuen Einladungslink ' +
'kommst du zurück.',
yes: async () => {
deletingAccount = true
deleteError = null
try {
await api.collection('users').delete(auth.user!.id)
app.confirm.close()
await auth.logout()
goto('/login', { replaceState: true })
} catch (e: any) {
app.confirm.close()
deleteError = e.message ?? 'Das Konto konnte nicht gelöscht werden.'
} finally {
deletingAccount = false
}
},
no: () => app.confirm.close(),
})
}
let oldPassword = $state('') let oldPassword = $state('')
let newPassword = $state('') let newPassword = $state('')
let confirmPassword = $state('') let confirmPassword = $state('')
@ -183,53 +109,36 @@
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<!-- <div class="flex items-center gap-4">
Das Bild steht neben Name und Adresse und reicht ueber beide <Avatar user={auth.user} size={64} />
Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt <div class="space-y-1">
unsichtbar darunter, angeklickt wird das Bild selbst — das <Label for="avatar">Bild</Label>
Label „Bild" ueber einem Dateifeld sagte nichts, was das Bild
nicht schon zeigt.
-->
<div class="flex items-start gap-4">
<label
for="avatar"
class="group relative shrink-0 cursor-pointer rounded-full ring-offset-2 ring-offset-card focus-within:ring-2 focus-within:ring-ring"
>
{#if avatarPreview}
<img
src={avatarPreview}
alt=""
class="size-20 rounded-full object-cover"
/>
{:else}
<Avatar user={auth.user} size={80} />
{/if}
<span
class="absolute inset-0 grid place-items-center rounded-full bg-black/55 text-white opacity-0 transition-opacity group-hover:opacity-100"
>
<Camera class="size-5" />
</span>
<input <input
id="avatar" id="avatar"
type="file" type="file"
accept="image/*" accept="image/*"
class="sr-only" class="block w-full text-sm"
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)} onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
/> />
</label> <p class="text-xs text-muted-foreground">
Ohne Bild stehen deine Initialen da.
<div class="min-w-0 flex-1 space-y-2"> </p>
<Input aria-label="Name" bind:value={name} placeholder="Wie du angezeigt wirst" />
<Input aria-label="E-Mail" value={auth.user?.email ?? ''} readonly disabled />
</div> </div>
</div> </div>
<div class="space-y-2">
<Label for="profile-name">Name</Label>
<Input id="profile-name" bind:value={name} placeholder="Wie du angezeigt wirst" />
</div>
<div class="space-y-2">
<Label for="profile-mail">E-Mail</Label>
<Input id="profile-mail" value={auth.user?.email ?? ''} readonly disabled />
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
Team-Administration über PocketBase. Team-Administration über PocketBase.
</p> </p>
</div>
{#if profileError} {#if profileError}
<p class="text-sm text-destructive">{profileError}</p> <p class="text-sm text-destructive">{profileError}</p>
@ -278,43 +187,4 @@
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
<!--
Eine eigene Karte, ganz unten: Was nicht rückgängig zu machen ist,
steht nicht zwischen Namensfeld und Passwortwechsel.
-->
<Card class="lg:col-span-2 border-destructive/30">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Trash2 class="size-4 text-destructive" />
Konto löschen
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
{#if ownedTeams.length > 0}
<p class="text-sm text-muted-foreground">
Du bist Owner von
{ownedTeams.map((t) => `„${t.name}"`).join(', ')}. Ein Team braucht
seinen Owner: Übergib es oder lösche es zuerst, danach lässt sich
dein Konto löschen.
</p>
<Button variant="outline" disabled>Konto löschen</Button>
{:else}
<p class="text-sm text-muted-foreground">
Dein Konto wird endgültig gelöscht und du wirst abgemeldet. Dein
Fahrer bleibt mit allen Zeiten und Teilnahmen im Kader stehen — nur
anmelden kannst du dich nicht mehr.
</p>
{#if deleteError}
<p class="text-sm text-destructive">{deleteError}</p>
{/if}
<Button variant="outline" onclick={deleteAccount} disabled={deletingAccount}>
<Trash2 class="size-4 mr-2 text-destructive" />
{deletingAccount ? 'Wird gelöscht …' : 'Konto löschen'}
</Button>
{/if}
</CardContent>
</Card>
</div> </div>

View file

@ -0,0 +1,587 @@
<script lang="ts">
/**
* Fahrer und Konten an einer Stelle — nur für die Teamleitung.
*
* Jedes Teammitglied ist ein Fahrer; ein Fahrer braucht aber kein Konto.
* Beides getrennt zu pflegen hieße, dieselben Menschen zweimal anzulegen
* und die Zuordnung von Hand zu halten. Die Liste zeigt deshalb den Kader
* und daneben, wer sich anmelden kann und mit welcher Rolle.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import Avatar from '$lib/components/Avatar.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import * as Table from '@/components/ui/table'
import {
Crown, KeyRound, Mail, Pencil, Plus, Shield, Trash2, Unlink, UserMinus,
} from 'lucide-svelte'
import type { RidersResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext()
const riders = getRiderContext()
const participants = getEventParticipantContext()
const canManage = $derived(riders.canManage)
const team = $derived(teams.active)
const iAmOwner = $derived(teams.isOwner())
$effect(() => {
teams.loadMembers()
})
type Row = {
rider?: RidersResponse
userId?: string
name: string
role: 'owner' | 'admin' | 'mitglied' | null
}
function roleOf(userId: string | undefined): Row['role'] {
if (!userId || !team) return null
if (team.owner === userId) return 'owner'
return (team.admins ?? []).includes(userId) ? 'admin' : 'mitglied'
}
/**
* Eine Zeile je Person: alle Fahrer des Kaders, dazu Konten im Team, die
* noch an keinem Fahrer hängen — sonst verschwänden sie aus der Ansicht,
* obwohl sie Mitglied sind.
*/
const rows = $derived.by((): Row[] => {
const list: Row[] = riders.scoped.map((rider) => ({
rider,
userId: rider.user || undefined,
name: fullName(rider) || '— ohne Name —',
role: roleOf(rider.user || undefined),
}))
const linked = new Set(riders.scoped.map((r) => r.user).filter(Boolean))
for (const userId of team?.users ?? []) {
if (linked.has(userId)) continue
list.push({ userId, name: teams.userLabel(userId), role: roleOf(userId) })
}
return list.sort((a, b) => a.name.localeCompare(b.name))
})
// --- Person anlegen ---------------------------------------------------
let personDialog = $state(false)
let editing = $state<RidersResponse | null>(null)
let form = $state({ firstname: '', lastname: '', withAccount: false, email: '', password: '' })
let busy = $state(false)
let error = $state<string | null>(null)
function openCreate() {
editing = null
form = { firstname: '', lastname: '', withAccount: false, email: '', password: '' }
error = null
personDialog = true
}
function openEdit(rider: RidersResponse) {
editing = rider
form = {
firstname: rider.firstname ?? '',
lastname: rider.lastname ?? '',
withAccount: false,
email: '',
password: '',
}
error = null
personDialog = true
}
async function savePerson() {
if (!form.firstname.trim() && !form.lastname.trim()) {
error = 'Bitte wenigstens einen Namen angeben.'
return
}
if (form.withAccount) {
if (!form.email.trim()) {
error = 'Für ein Konto braucht es eine E-Mail-Adresse.'
return
}
if (form.password.length < 8) {
error = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
}
busy = true
error = null
try {
const data = { firstname: form.firstname.trim(), lastname: form.lastname.trim() }
if (editing) {
await riders.edit(editing.id, data)
} else {
const rider = await riders.create(data)
if (form.withAccount) {
await riders.createLogin(rider as RidersResponse, {
email: form.email,
password: form.password,
})
}
}
personDialog = false
} catch (e: any) {
error = e.message ?? 'Die Person konnte nicht gespeichert werden.'
} finally {
busy = false
}
}
// --- Konto zu einem vorhandenen Fahrer --------------------------------
let accountDialog = $state(false)
let accountRider = $state<RidersResponse | null>(null)
let accountMode = $state<'neu' | 'vorhanden'>('neu')
let accountForm = $state({ email: '', password: '' })
let searchEmail = $state('')
let searching = $state(false)
let found = $state<UsersResponse | null>(null)
let accountBusy = $state(false)
let accountError = $state<string | null>(null)
function openAccount(rider: RidersResponse) {
accountRider = rider
accountMode = 'neu'
accountForm = { email: '', password: '' }
searchEmail = ''
found = null
accountError = null
accountDialog = true
}
async function createAccount() {
if (!accountRider) return
if (!accountForm.email.trim()) {
accountError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (accountForm.password.length < 8) {
accountError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
accountBusy = true
accountError = null
try {
await riders.createLogin(accountRider, { ...accountForm })
accountDialog = false
} catch (e: any) {
accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
accountBusy = false
}
}
async function searchAccount() {
if (!searchEmail.trim()) return
searching = true
accountError = null
found = null
try {
const user = await teams.findUserByEmail(searchEmail.trim())
if (user) found = user
else accountError = `Kein Konto mit „${searchEmail}" gefunden.`
} catch (e: any) {
accountError = e.message ?? 'Die Suche ist fehlgeschlagen.'
} finally {
searching = false
}
}
async function linkFound() {
if (!accountRider || !found || !team) return
accountBusy = true
accountError = null
try {
// Erst ins Team, dann verknüpfen: Ein Konto, das nicht im Team
// ist, sähe in der Liste wie ein Mitglied aus, ohne eines zu sein.
if (!(team.users ?? []).includes(found.id)) {
await teams.addMember(team.id, found.id)
}
await riders.linkUser(accountRider.id, found.id)
accountDialog = false
} catch (e: any) {
accountError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
} finally {
accountBusy = false
}
}
async function unlink(rider: RidersResponse) {
if (!confirm('Verknüpfung lösen? Das Konto bleibt bestehen, der Fahrer auch.')) return
try {
await riders.linkUser(rider.id, null)
} catch (e: any) {
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
}
}
/** Ein Konto ohne Fahrer nachträglich in den Kader holen. */
async function adoptAccount(userId: string) {
const user = teams.users[userId]
try {
const rider = await riders.create({
firstname: user?.name ?? '',
lastname: '',
})
await riders.linkUser(rider.id, userId)
} catch (e: any) {
alert(e.message ?? 'Der Fahrer konnte nicht angelegt werden.')
}
}
async function removeFromTeam(userId: string) {
if (!team) return
if (userId === team.owner) return alert('Der Owner kann nicht entfernt werden.')
if (!confirm('Konto aus dem Team entfernen? Der Fahrer bleibt im Kader.')) return
try {
await teams.removeMember(team.id, userId)
} catch (e: any) {
alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.')
}
}
async function toggleAdmin(userId: string, makeAdmin: boolean) {
if (!team) return
try {
if (makeAdmin) await teams.promoteToAdmin(team.id, userId)
else await teams.demoteFromAdmin(team.id, userId)
} catch (e: any) {
alert(e.message ?? 'Die Rolle konnte nicht geändert werden.')
}
}
</script>
{#if !canManage}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Fahrer und Konten verwaltet, wer das Team führt. Den Kader siehst du
unter <a href="/dashboard/riders" class="underline">Fahrer</a>.
</CardContent>
</Card>
{:else}
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted-foreground">
Jedes Teammitglied ist ein Fahrer — ein Konto braucht es dafür nicht.
Wer sich anmelden können soll, bekommt eins.
</p>
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
Person anlegen
</Button>
</div>
{#if rows.length === 0}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Noch niemand im Team.
</CardContent>
</Card>
{:else}
<Card>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head class="w-56">Konto</Table.Head>
<Table.Head class="w-32">Rolle</Table.Head>
<Table.Head class="w-24">Events</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (row.rider?.id ?? row.userId)}
<Table.Row>
<Table.Cell class="font-medium">
<span class="flex items-center gap-2">
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
{row.name}
{#if !row.rider}
<Badge variant="outline">kein Fahrer</Badge>
{/if}
</span>
</Table.Cell>
<Table.Cell>
{#if row.userId}
<span class="text-sm text-muted-foreground truncate">
{teams.userLabel(row.userId)}
</span>
{:else}
<span class="text-sm text-muted-foreground">kein Konto</span>
{/if}
</Table.Cell>
<Table.Cell>
{#if row.role === 'owner'}
<Badge class="gap-1">
<Crown class="size-3" />
Owner
</Badge>
{:else if row.role === 'admin'}
<Badge variant="secondary" class="gap-1">
<Shield class="size-3" />
Admin
</Badge>
{:else if row.role === 'mitglied'}
<Badge variant="outline">Mitglied</Badge>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{row.rider ? participants.byRider(row.rider.id).length : '—'}
</Table.Cell>
<Table.Cell class="text-right whitespace-nowrap">
{#if iAmOwner && row.userId && row.role !== 'owner'}
<Button
variant="ghost"
size="sm"
onclick={() => toggleAdmin(row.userId!, row.role !== 'admin')}
>
{row.role === 'admin' ? 'Admin entziehen' : 'Zu Admin'}
</Button>
{/if}
{#if row.rider && !row.userId}
<Button
variant="ghost"
size="icon"
tooltip="Konto anlegen oder verknüpfen"
onclick={() => openAccount(row.rider!)}
>
<KeyRound class="size-4" />
</Button>
{:else if row.rider && row.userId}
<Button
variant="ghost"
size="icon"
tooltip="Verknüpfung lösen"
onclick={() => unlink(row.rider!)}
>
<Unlink class="size-4" />
</Button>
{:else if row.userId}
<Button
variant="ghost"
size="sm"
onclick={() => adoptAccount(row.userId!)}
>
Als Fahrer übernehmen
</Button>
{/if}
{#if row.rider}
<Button
variant="ghost"
size="icon"
tooltip="Bearbeiten"
onclick={() => openEdit(row.rider!)}
>
<Pencil class="size-4" />
</Button>
{/if}
{#if row.userId && row.role !== 'owner'}
<Button
variant="ghost"
size="icon"
tooltip="Aus dem Team entfernen"
onclick={() => removeFromTeam(row.userId!)}
>
<UserMinus class="size-4" />
</Button>
{/if}
{#if row.rider}
<Button
variant="ghost"
size="icon"
tooltip="Fahrer löschen"
onclick={() => riders.remove(row.rider!)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
{/if}
</div>
{/if}
<!-- Person anlegen oder bearbeiten -->
<Dialog.Root bind:open={personDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Person anlegen'}</Dialog.Title>
<Dialog.Description>
{editing
? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.'
: 'Kommt als Fahrer in den Kader. Ein Konto ist optional.'}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="p-first">Vorname</Label>
<Input id="p-first" bind:value={form.firstname} placeholder="Max" />
</div>
<div class="space-y-2">
<Label for="p-last">Nachname</Label>
<Input id="p-last" bind:value={form.lastname} placeholder="Mustermann" />
</div>
</div>
{#if !editing}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={form.withAccount}
class="size-4 rounded border-input accent-primary"
/>
kann sich anmelden
</label>
{#if form.withAccount}
<div class="space-y-2">
<Label for="p-mail">E-Mail</Label>
<Input id="p-mail" type="email" bind:value={form.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="p-pw">Passwort</Label>
<Input id="p-pw" type="password" bind:value={form.password} placeholder="mindestens 8 Zeichen" />
<p class="text-xs text-muted-foreground">
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
sich unter „Profil" ändern.
</p>
</div>
{/if}
{/if}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (personDialog = false)}>Abbrechen</Button>
<Button onclick={savePerson} disabled={busy}>
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Konto für einen vorhandenen Fahrer -->
<Dialog.Root bind:open={accountDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
Konto für {accountRider ? fullName(accountRider) : 'Fahrer'}
</Dialog.Title>
<Dialog.Description>
Mit einem Konto kann sich die Person selbst anmelden.
</Dialog.Description>
</Dialog.Header>
<div class="flex gap-2">
<Button
variant={accountMode === 'neu' ? 'default' : 'outline'}
size="sm"
onclick={() => (accountMode = 'neu')}
>
Neues anlegen
</Button>
<Button
variant={accountMode === 'vorhanden' ? 'default' : 'outline'}
size="sm"
onclick={() => (accountMode = 'vorhanden')}
>
Vorhandenes verknüpfen
</Button>
</div>
<div class="space-y-4">
{#if accountMode === 'neu'}
<div class="space-y-2">
<Label for="a-mail">E-Mail</Label>
<Input id="a-mail" type="email" bind:value={accountForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="a-pw">Passwort</Label>
<Input id="a-pw" type="password" bind:value={accountForm.password} placeholder="mindestens 8 Zeichen" />
</div>
{:else}
<div class="space-y-2">
<Label for="a-search">E-Mail des Kontos</Label>
<div class="flex gap-2">
<Input
id="a-search"
type="email"
bind:value={searchEmail}
placeholder="max@example.com"
onkeydown={(e) => e.key === 'Enter' && searchAccount()}
/>
<Button variant="outline" onclick={searchAccount} disabled={searching}>
<Mail class="size-4 mr-1" />
{searching ? 'Suche …' : 'Suchen'}
</Button>
</div>
</div>
{#if found}
<div class="flex items-center gap-3 rounded-lg border bg-accent/40 p-3">
<Avatar user={found} size={36} />
<div class="min-w-0">
<p class="font-medium truncate">{found.name || found.email}</p>
<p class="text-xs text-muted-foreground">
Wird ins Team aufgenommen und verknüpft.
</p>
</div>
</div>
{/if}
{/if}
{#if accountError}
<p class="text-sm text-destructive">{accountError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
{#if accountMode === 'neu'}
<Button onclick={createAccount} disabled={accountBusy}>
{accountBusy ? 'Wird angelegt …' : 'Konto anlegen'}
</Button>
{:else}
<Button onclick={linkFound} disabled={!found || accountBusy}>Verknüpfen</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,352 @@
<script lang="ts">
/**
* Team-Verwaltung. Owner und Admins ändern hier die Team-Daten und die
* Mitglieder; alle anderen sehen dieselbe Liste ohne Knöpfe.
*/
import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRiderContext } from '$lib/stores/riders.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import Avatar from '$lib/components/Avatar.svelte'
import Logo from '$lib/components/Logo.svelte'
import {
Bike, Calendar, Image, LogOut, Plus, Route, Trash2, Users,
} from 'lucide-svelte'
import type { TeamsResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext()
const flags = getTrailFlagContext()
const events = getEventContext()
const riders = getRiderContext()
const trails = getTrailContext()
const markers = getTrailMarkerContext()
const me = $derived(api.authStore.record?.id ?? null)
const accounts = $derived(riders.scoped.filter((r) => r.user).length)
const openMarkers = $derived(
trails.scoped.reduce((sum, t) => sum + markers.openByTrail(t.id).length, 0),
)
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
$effect(() => {
for (const t of teams.records) teams.loadMembers(t)
})
// ---- Team anlegen ----
let createDialog = $state(false)
let createName = $state('')
let createError = $state<string | null>(null)
async function doCreate() {
if (!createName.trim()) return
createError = null
try {
const team = await teams.create(createName.trim())
// Ein Team ohne Flag-Typen kann zwar Marker aufnehmen, aber nichts
// einordnen. Deshalb kommt der Standardsatz gleich mit — änderbar
// und, wie alle Flag-Typen, allein Sache dieses Teams.
try {
await flags.seedDefaults(team.id)
} catch (e: any) {
createError =
'Das Team wurde angelegt, die Standard-Flag-Typen aber nicht: ' +
(e.message ?? 'unbekannter Fehler') +
' Du kannst sie unter „Flag-Typen" nachholen.'
return
}
createDialog = false
createName = ''
} catch (e: any) {
createError = e.message ?? 'Das Team konnte nicht angelegt werden.'
}
}
// ---- Team umbenennen ----
let renameDialog = $state(false)
let renameTeam = $state<TeamsResponse | null>(null)
let renameValue = $state('')
let renameError = $state<string | null>(null)
function openRename(t: TeamsResponse) {
renameTeam = t
renameValue = t.name
renameError = null
renameDialog = true
}
async function doRename() {
if (!renameTeam || !renameValue.trim()) return
renameError = null
try {
await teams.rename(renameTeam.id, renameValue.trim())
renameDialog = false
} catch (e: any) {
renameError = e.message ?? 'Der Name konnte nicht gespeichert werden.'
}
}
// Kennzahlen des aktiven Teams — was hier steht, gehört dem Team und
// wandert beim Wechsel mit.
const stats = $derived([
{
label: 'Events',
value: events.scoped.length,
hint: `${events.upcoming.length} anstehend`,
icon: Calendar,
},
{ label: 'Fahrer', value: riders.scoped.length, hint: `${accounts} mit Konto`, icon: Bike },
{
label: 'Trails',
value: trails.scoped.length,
hint: `${openMarkers} offene Meldungen`,
icon: Route,
},
{ label: 'Mitglieder', value: (teams.active?.users ?? []).length, icon: Users },
])
// ---- Logo ----
let logoDialog = $state(false)
let logoTeam = $state<TeamsResponse | null>(null)
let logoFile = $state<File | null>(null)
let logoBusy = $state(false)
let logoError = $state<string | null>(null)
function openLogo(t: TeamsResponse) {
logoTeam = t
logoFile = null
logoError = null
logoDialog = true
}
async function saveLogo(file: File | null) {
if (!logoTeam) return
logoBusy = true
logoError = null
try {
await teams.setLogo(logoTeam.id, file)
logoDialog = false
} catch (e: any) {
logoError = e.message ?? 'Das Logo konnte nicht gespeichert werden.'
} finally {
logoBusy = false
}
}
// ---- Aktionen ----
async function doLeave(t: TeamsResponse) {
if (!confirm(`Team „${t.name}" wirklich verlassen?`)) return
try {
await teams.leave(t.id)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
</script>
<div class="space-y-6">
{#if teams.active}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each stats as stat (stat.label)}
{@const Icon = stat.icon}
<Card>
<CardContent class="py-4">
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">{stat.label}</p>
<Icon class="size-4 text-muted-foreground" />
</div>
<p class="mt-1 text-2xl font-bold">{stat.value}</p>
{#if stat.hint}
<p class="text-xs text-muted-foreground">{stat.hint}</p>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
<div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">
Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und
Trails du siehst.
</p>
<Button onclick={() => (createDialog = true)}>
<Plus class="size-4 mr-2" />
Neues Team
</Button>
</div>
{#if teams.records.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch in keinem Team</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Erstelle dein erstes Team.</p>
<Button onclick={() => (createDialog = true)}>
<Plus class="size-4 mr-2" />
Team erstellen
</Button>
</CardContent>
</Card>
{:else}
{#each teams.records as t (t.id)}
{@const iAmOwner = t.owner === me}
{@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')}
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="flex items-center gap-3">
<Logo team={t} class="size-10 shrink-0" />
<div>
<CardTitle class="flex items-center gap-2">
{t.name}
{#if t.id === teams.activeId}
<Badge>Aktiv</Badge>
{/if}
</CardTitle>
<CardDescription>
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
</CardDescription>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if t.id !== teams.activeId}
<Button variant="outline" size="sm" onclick={() => teams.setActive(t.id)}>
Aktivieren
</Button>
{/if}
{#if iAmAdmin}
<Button variant="outline" size="sm" onclick={() => openRename(t)}>
Umbenennen
</Button>
<Button variant="outline" size="sm" onclick={() => openLogo(t)}>
<Image class="size-4 mr-1" />
Logo
</Button>
{/if}
{#if !iAmOwner}
<Button variant="ghost" size="sm" onclick={() => doLeave(t)}>
<LogOut class="size-4 mr-1" />
Verlassen
</Button>
{:else}
<Button variant="ghost" size="icon" tooltip="Team löschen" onclick={() => teams.remove(t)}>
<Trash2 class="size-4 text-destructive" />
</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
· verwaltet unter <a href="/dashboard/settings/riders" class="underline">Fahrer</a>
</CardContent>
</Card>
{/each}
{/if}
</div>
<!-- Team anlegen -->
<Dialog.Root bind:open={createDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neues Team</Dialog.Title>
<Dialog.Description>Du wirst automatisch Owner.</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-name">Name</Label>
<Input id="team-name" bind:value={createName} placeholder="Mein Team" />
</div>
{#if createError}
<p class="text-sm text-destructive">{createError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (createDialog = false)}>Abbrechen</Button>
<Button onclick={doCreate} disabled={!createName.trim()}>Erstellen</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Team umbenennen -->
<Dialog.Root bind:open={renameDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Team umbenennen</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-rename">Name</Label>
<Input id="team-rename" bind:value={renameValue} />
</div>
{#if renameError}
<p class="text-sm text-destructive">{renameError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (renameDialog = false)}>Abbrechen</Button>
<Button onclick={doRename} disabled={!renameValue.trim()}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Logo -->
<Dialog.Root bind:open={logoDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
<Dialog.Description>
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
Logo steht dort das Wappen des Stammtischs.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="flex items-center gap-4">
<Logo team={logoTeam} class="size-20 shrink-0" />
<div class="space-y-1 min-w-0">
<Label for="team-logo">Datei</Label>
<input
id="team-logo"
type="file"
accept="image/*"
class="block w-full text-sm"
onchange={(e) => (logoFile = e.currentTarget.files?.[0] ?? null)}
/>
<p class="text-xs text-muted-foreground">PNG, SVG oder WebP, bis 5 MB.</p>
</div>
</div>
{#if logoError}
<p class="text-sm text-destructive">{logoError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (logoDialog = false)}>Abbrechen</Button>
{#if logoTeam?.logo}
<Button variant="outline" disabled={logoBusy} onclick={() => saveLogo(null)}>
Entfernen
</Button>
{/if}
<Button disabled={!logoFile || logoBusy} onclick={() => saveLogo(logoFile)}>
{logoBusy ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,953 +0,0 @@
<script lang="ts">
/**
* Das Team an einer Stelle: oben, wofür das Team steht — Kennzahlen,
* Name, Logo, die eigenen Teams. Darunter der Kader.
*
* Früher lag beides in den Einstellungen und ein blasses Verzeichnis
* zusätzlich unter „Fahrer". Wer einen Fahrer anlegen wollte, landete
* erst im Verzeichnis, dann in den Einstellungen. Jetzt gibt es einen
* Ort, und die Verwaltungsknöpfe stehen an den Zeilen, zu denen sie
* gehören — sichtbar nur für die Teamleitung.
*
* Jedes Teammitglied ist ein Fahrer; ein Fahrer braucht aber kein Konto.
* Ein Konto entsteht deshalb immer am Fahrer und nie umgekehrt — es gibt
* kein Verknüpfen und kein Lösen, nur „dieser Fahrer bekommt ein Login".
*
* Und es gibt genau einen Namen: den des Fahrers. Der Kontoname taucht in
* dieser Tabelle nirgends auf. Zwei Namen für dieselbe Person liefen
* auseinander, und geradeziehen ließe sie niemand: users.updateRule
* erlaubt Änderungen nur am eigenen Konto, an fremde Kontonamen kommt
* auch die Teamleitung nicht heran. Beim Anlegen setzt riders.createLogin
* den Kontonamen einmal auf den Fahrernamen; ab da zählt der Fahrer.
*/
import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTeamInviteContext } from '$lib/stores/teamInvites.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import Avatar from '$lib/components/Avatar.svelte'
import Logo from '$lib/components/Logo.svelte'
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import * as Table from '@/components/ui/table'
import {
Bike, Calendar, Check, Copy, Crown, Image, KeyRound, Link2, LogOut, Pencil,
Plus, Route, Search, Shield, Trash2, UserCheck, UserMinus, Users,
} from 'lucide-svelte'
import type { RidersResponse, TeamInvitesResponse, TeamsResponse } from '$lib/types'
const teams = getTeamContext()
const invites = getTeamInviteContext()
const riders = getRiderContext()
const events = getEventContext()
const times = getTimeContext()
const participants = getEventParticipantContext()
const trails = getTrailContext()
const markers = getTrailMarkerContext()
const me = $derived(api.authStore.record?.id ?? null)
const team = $derived(teams.active)
const canManage = $derived(teams.isAdmin())
const iAmOwner = $derived(teams.isOwner())
const accounts = $derived(riders.scoped.filter((r) => r.user).length)
const openMarkers = $derived(
trails.scoped.reduce((sum, t) => sum + markers.openByTrail(t.id).length, 0),
)
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
$effect(() => {
for (const t of teams.records) teams.loadMembers(t)
})
const stats = $derived([
{
label: 'Events',
value: events.scoped.length,
hint: `${events.upcoming.length} anstehend`,
icon: Calendar,
},
{ label: 'Fahrer', value: riders.scoped.length, hint: `${accounts} mit Konto`, icon: Bike },
{
label: 'Trails',
value: trails.scoped.length,
hint: `${openMarkers} offene Meldungen`,
icon: Route,
},
{ label: 'Mitglieder', value: (team?.users ?? []).length, icon: Users },
])
// ---- Kader ----------------------------------------------------------
let query = $state('')
type Row = {
rider?: RidersResponse
userId?: string
name: string
role: 'owner' | 'admin' | 'mitglied' | null
}
function roleOf(userId: string | undefined): Row['role'] {
if (!userId || !team) return null
if (team.owner === userId) return 'owner'
return (team.admins ?? []).includes(userId) ? 'admin' : 'mitglied'
}
/**
* Eine Zeile je Person: alle Fahrer des Kaders, dazu Konten im Team, die
* an keinem Fahrer hängen — sonst verschwänden sie aus der Ansicht,
* obwohl sie Mitglied sind. Neu entstehen solche Zeilen nicht mehr:
* Sowohl der Einladungslink als auch „Konto anlegen" führen beides
* zusammen.
*/
const rows = $derived.by((): Row[] => {
const list: Row[] = riders.scoped.map((rider) => ({
rider,
userId: rider.user || undefined,
name: riderName(rider) || '— ohne Name —',
role: roleOf(rider.user || undefined),
}))
const linked = new Set(riders.scoped.map((r) => r.user).filter(Boolean))
for (const userId of team?.users ?? []) {
if (linked.has(userId)) continue
list.push({ userId, name: teams.userLabel(userId), role: roleOf(userId) })
}
return list.sort((a, b) => a.name.localeCompare(b.name))
})
const filtered = $derived.by(() => {
const q = query.toLowerCase().trim()
return q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows
})
/**
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen an der Stage und
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
*/
function riderPath(riderId: string) {
const latest = participants.byRider(riderId).at(-1)
return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null
}
// ---- Person anlegen oder bearbeiten ----------------------------------
//
// Der Dialog bearbeitet die Person, nicht nur den Fahrer: Name und Rolle
// im Team stehen beieinander. Die Rolle lag früher als „Zu Admin" in der
// Zeile — ein Knopf, der ohne Rückfrage Rechte vergab, direkt neben den
// Icons fürs Entfernen und Löschen.
//
// `editing` ist der Fahrer und darf leer bleiben: Alte Datensätze kennen
// Konten im Team, an denen kein Fahrer hängt. Ob der Dialog anlegt oder
// bearbeitet, sagt deshalb `isEdit`.
let personDialog = $state(false)
let isEdit = $state(false)
let editing = $state<RidersResponse | null>(null)
let editingUser = $state<string | null>(null)
let form = $state({ name: '', withAccount: false, email: '', password: '', admin: false })
let busy = $state(false)
let error = $state<string | null>(null)
/** Rolle der gerade bearbeiteten Person, mitlaufend mit dem Team. */
const editingRole = $derived(roleOf(editingUser ?? undefined))
/** Die Rolle vergibt allein der Owner, und nie die eigene. */
const canSetRole = $derived(iAmOwner && !!editingUser && editingRole !== 'owner')
function openCreate() {
isEdit = false
editing = null
editingUser = null
form = { name: '', withAccount: false, email: '', password: '', admin: false }
error = null
personDialog = true
}
function openEdit(row: Row) {
isEdit = true
editing = row.rider ?? null
editingUser = row.userId ?? null
form = {
name: row.rider?.name ?? '',
withAccount: false,
email: '',
password: '',
admin: row.role === 'admin',
}
error = null
personDialog = true
}
async function savePerson() {
if ((editing || !isEdit) && !form.name.trim()) {
error = 'Bitte einen Namen angeben.'
return
}
if (form.withAccount) {
if (!form.email.trim()) {
error = 'Für ein Konto braucht es eine E-Mail-Adresse.'
return
}
if (form.password.length < 8) {
error = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
}
busy = true
error = null
try {
const data = { name: form.name.trim() }
if (isEdit) {
if (editing) await riders.edit(editing.id, data)
// Rolle nur anfassen, wenn sie sich wirklich unterscheidet —
// sonst schriebe jedes Speichern die admins-Liste neu.
if (canSetRole && team && editingUser && form.admin !== (editingRole === 'admin')) {
if (form.admin) await teams.promoteToAdmin(team.id, editingUser)
else await teams.demoteFromAdmin(team.id, editingUser)
}
} else {
const rider = await riders.create(data)
if (form.withAccount) {
await riders.createLogin(rider as RidersResponse, {
email: form.email,
password: form.password,
})
}
}
personDialog = false
} catch (e: any) {
error = e.message ?? 'Die Person konnte nicht gespeichert werden.'
} finally {
busy = false
}
}
// ---- Konto zu einem vorhandenen Fahrer -------------------------------
//
// Nur diese eine Richtung: aus einem Fahrer ohne Konto einen mit Konto
// machen. Ein bestehendes Konto an einen Fahrer zu hängen oder die
// Verbindung wieder zu lösen gibt es bewusst nicht — wer sich anmelden
// können soll, bekommt hier ein Konto oder kommt über den Einladungslink.
let accountDialog = $state(false)
let accountRider = $state<RidersResponse | null>(null)
let accountForm = $state({ email: '', password: '' })
let accountBusy = $state(false)
let accountError = $state<string | null>(null)
function openAccount(rider: RidersResponse) {
accountRider = rider
accountForm = { email: '', password: '' }
accountError = null
accountDialog = true
}
async function createAccount() {
if (!accountRider) return
if (!accountForm.email.trim()) {
accountError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (accountForm.password.length < 8) {
accountError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
accountBusy = true
accountError = null
try {
await riders.createLogin(accountRider, { ...accountForm })
accountDialog = false
} catch (e: any) {
accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
accountBusy = false
}
}
async function removeFromTeam(userId: string) {
if (!team) return
if (userId === team.owner) return alert('Der Owner kann nicht entfernt werden.')
if (!confirm('Konto aus dem Team entfernen? Der Fahrer bleibt im Kader.')) return
try {
await teams.removeMember(team.id, userId)
} catch (e: any) {
alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.')
}
}
// ---- Einladungslinks -------------------------------------------------
let inviteDialog = $state(false)
let inviteForm = $state({ days: 14, maxUses: 0 })
let inviteBusy = $state(false)
let inviteError = $state<string | null>(null)
let copied = $state<string | null>(null)
async function createInvite() {
inviteBusy = true
inviteError = null
try {
const invite = await invites.create({ days: inviteForm.days, maxUses: inviteForm.maxUses })
// Gleich in der Zwischenablage: Wer einen Link erzeugt, will ihn
// verschicken, nicht bewundern.
await copyLink(invite)
} catch (e: any) {
inviteError = e.message ?? 'Der Link konnte nicht erzeugt werden.'
} finally {
inviteBusy = false
}
}
async function copyLink(invite: TeamInvitesResponse) {
try {
await navigator.clipboard.writeText(invites.url(invite))
copied = invite.id
setTimeout(() => (copied = copied === invite.id ? null : copied), 2000)
} catch {
// Ohne Zwischenablage-Recht bleibt der Link im Feld daneben
// stehen und lässt sich von Hand markieren.
}
}
async function removeInvite(invite: TeamInvitesResponse) {
try {
await invites.remove(invite.id)
} catch (e: any) {
alert(e.message ?? 'Der Link konnte nicht zurückgezogen werden.')
}
}
function inviteHint(invite: TeamInvitesResponse) {
const parts: string[] = []
if (!invites.isValid(invite)) parts.push('abgelaufen')
else if (invite.expires) {
parts.push(`gültig bis ${new Date(invite.expires).toLocaleDateString('de-DE')}`)
} else parts.push('ohne Ablauf')
const max = invite.max_uses ?? 0
parts.push(max > 0 ? `${invite.uses ?? 0} von ${max} genutzt` : `${invite.uses ?? 0}× genutzt`)
return parts.join(' · ')
}
// ---- Team anlegen ----------------------------------------------------
// Dialog und Anlegelogik stecken in CreateTeamDialog — derselbe Weg wie
// über den Teamwechsler im Kopf.
let createDialog = $state(false)
// ---- Team umbenennen -------------------------------------------------
let renameDialog = $state(false)
let renameTeam = $state<TeamsResponse | null>(null)
let renameValue = $state('')
let renameError = $state<string | null>(null)
function openRename(t: TeamsResponse) {
renameTeam = t
renameValue = t.name
renameError = null
renameDialog = true
}
async function doRename() {
if (!renameTeam || !renameValue.trim()) return
renameError = null
try {
await teams.rename(renameTeam.id, renameValue.trim())
renameDialog = false
} catch (e: any) {
renameError = e.message ?? 'Der Name konnte nicht gespeichert werden.'
}
}
// ---- Logo ------------------------------------------------------------
let logoDialog = $state(false)
let logoTeam = $state<TeamsResponse | null>(null)
let logoFile = $state<File | null>(null)
let logoBusy = $state(false)
let logoError = $state<string | null>(null)
function openLogo(t: TeamsResponse) {
logoTeam = t
logoFile = null
logoError = null
logoDialog = true
}
async function saveLogo(file: File | null) {
if (!logoTeam) return
logoBusy = true
logoError = null
try {
await teams.setLogo(logoTeam.id, file)
logoDialog = false
} catch (e: any) {
logoError = e.message ?? 'Das Logo konnte nicht gespeichert werden.'
} finally {
logoBusy = false
}
}
async function doLeave(t: TeamsResponse) {
if (!confirm(`Team „${t.name}" wirklich verlassen?`)) return
try {
await teams.leave(t.id)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
</script>
<svelte:head><title>Team</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<!--
Kein Logo neben der Ueberschrift: Auf dieser Seite ist immer das
aktive Team gemeint, und dessen Logo steht wenige Pixel darueber
schon in der Kopfleiste. Zweimal dasselbe Bild uebereinander sagt
nicht mehr als einmal.
-->
<div>
<h1 class="text-3xl font-bold tracking-tight">{team?.name ?? 'Team'}</h1>
<p class="mt-1 text-muted-foreground">
{(team?.users ?? []).length} Mitglied{(team?.users ?? []).length === 1 ? '' : 'er'}
· {riders.scoped.length} im Kader
</p>
</div>
<!--
Alles hier gilt dem aktiven Team — auf dieser Seite ist kein
anderes gemeint. „Verlassen" stand frueher in der Liste „Meine
Teams"; die ist entfallen, weil Wechseln und Anlegen im Submenue
am Menuepunkt Team stehen. Verlassen kann man nur dieses eine,
also gehoert es hierher.
-->
{#if team}
<div class="flex flex-wrap items-center gap-2">
{#if canManage}
<Button variant="outline" size="sm" onclick={() => openRename(team)}>
<Pencil class="size-4 mr-1" />
Umbenennen
</Button>
<Button variant="outline" size="sm" onclick={() => openLogo(team)}>
<Image class="size-4 mr-1" />
Logo
</Button>
<Button variant="outline" size="sm" onclick={() => (inviteDialog = true)}>
<Link2 class="size-4 mr-1" />
Einladungslink
</Button>
{/if}
{#if iAmOwner}
<Button variant="ghost" size="icon" tooltip="Team löschen" onclick={() => teams.remove(team)}>
<Trash2 class="size-4 text-destructive" />
</Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => doLeave(team)}>
<LogOut class="size-4 mr-1" />
Verlassen
</Button>
{/if}
</div>
{/if}
</div>
{#if !team}
<Card>
<CardContent class="py-16 text-center">
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch in keinem Team</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Erstelle dein erstes Team.</p>
<Button onclick={() => (createDialog = true)}>
<Plus class="size-4 mr-2" />
Team erstellen
</Button>
</CardContent>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each stats as stat (stat.label)}
{@const Icon = stat.icon}
<Card>
<CardContent>
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">{stat.label}</p>
<Icon class="size-4 text-muted-foreground" />
</div>
<p class="mt-1 text-2xl font-bold">{stat.value}</p>
{#if stat.hint}
<p class="text-xs text-muted-foreground">{stat.hint}</p>
{/if}
</CardContent>
</Card>
{/each}
</div>
<!-- Kader -->
<Card>
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="relative w-full max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input bind:value={query} placeholder="Name…" class="pl-9" />
</div>
{#if canManage}
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
Person anlegen
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-0">
{#if rows.length === 0}
<div class="py-16 text-center">
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch niemand im Team</p>
<p class="text-sm text-muted-foreground mt-1">
{#if canManage}
Leg die erste Person an oder verschick einen Einladungslink.
{:else}
Die Teamleitung legt den Kader an.
{/if}
</p>
</div>
{:else if filtered.length === 0}
<div class="py-12 text-center text-muted-foreground">Keine Treffer.</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head class="w-48">Konto</Table.Head>
<Table.Head class="w-32">Rolle</Table.Head>
<Table.Head class="w-24">Events</Table.Head>
<Table.Head class="w-24 text-right">Zeiten</Table.Head>
{#if canManage}
<Table.Head class="text-right">Aktionen</Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as row (row.rider?.id ?? row.userId)}
{@const path = row.rider ? riderPath(row.rider.id) : null}
<Table.Row>
<Table.Cell class="font-medium">
<span class="flex items-center gap-2">
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
{#if path}
<a href={path} class="hover:underline">{row.name}</a>
{:else}
{row.name}
{/if}
{#if !row.rider}
<Badge variant="outline">kein Fahrer</Badge>
{/if}
</span>
</Table.Cell>
<!--
Hier steht bewusst KEIN zweiter Name.
Der Name der Person steht am Fahrer,
der Kontoname wäre eine zweite
Wahrheit, die auseinanderläuft — und
die niemand geradeziehen könnte:
users.updateRule lässt nur den
Kontoinhaber selbst ändern, kein Admin
kommt an fremde Kontonamen heran. Das
Konto ist deshalb nur ein Ja oder
Nein: kann sich anmelden.
-->
<Table.Cell>
{#if row.userId}
<Badge variant="secondary" class="gap-1">
<UserCheck class="size-3" />
kann sich anmelden
</Badge>
{:else}
<span class="text-sm text-muted-foreground">kein Konto</span>
{/if}
</Table.Cell>
<Table.Cell>
{#if row.role === 'owner'}
<Badge class="gap-1">
<Crown class="size-3" />
Owner
</Badge>
{:else if row.role === 'admin'}
<Badge variant="secondary" class="gap-1">
<Shield class="size-3" />
Admin
</Badge>
{:else if row.role === 'mitglied'}
<Badge variant="outline">Mitglied</Badge>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-sm text-muted-foreground">
{row.rider ? participants.byRider(row.rider.id).length : '—'}
</Table.Cell>
<Table.Cell class="text-right text-sm text-muted-foreground">
{row.rider ? times.getByRider(row.rider.id).length : '—'}
</Table.Cell>
{#if canManage}
<Table.Cell class="text-right whitespace-nowrap">
{#if row.rider && !row.userId}
<Button
variant="ghost"
size="icon"
tooltip="Konto anlegen"
onclick={() => openAccount(row.rider!)}
>
<KeyRound class="size-4" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
tooltip="Bearbeiten"
onclick={() => openEdit(row)}
>
<Pencil class="size-4" />
</Button>
{#if row.userId && row.role !== 'owner'}
<Button
variant="ghost"
size="icon"
tooltip="Aus dem Team entfernen"
onclick={() => removeFromTeam(row.userId!)}
>
<UserMinus class="size-4" />
</Button>
{/if}
{#if row.rider}
<Button
variant="ghost"
size="icon"
tooltip="Fahrer löschen"
onclick={() => riders.remove(row.rider!)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
{/if}
</Table.Cell>
{/if}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</CardContent>
</Card>
{/if}
</div>
<!-- Person anlegen oder bearbeiten -->
<Dialog.Root bind:open={personDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{isEdit ? 'Person bearbeiten' : 'Person anlegen'}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.'
: 'Kommt als Fahrer in den Kader. Ein Konto ist optional.'}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
{#if editing || !isEdit}
<div class="space-y-2">
<Label for="p-name">Name</Label>
<Input id="p-name" bind:value={form.name} placeholder="Max Mustermann" />
</div>
{/if}
<!--
Rolle im Team. Nur der Owner sieht sie, und nie an sich selbst
— den Owner gibt es genau einmal, er steht am Team und nicht
in der Adminliste.
-->
{#if canSetRole}
<Separator />
<div class="space-y-2">
<Label>Rolle im Team</Label>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={form.admin}
class="size-4 rounded border-input accent-primary"
/>
Admin
</label>
<p class="text-xs text-muted-foreground">
Admins verwalten Kader, Einladungslinks und Teamdaten. Löschen und
Rollen bleiben beim Owner.
</p>
</div>
{:else if isEdit && editingRole === 'owner'}
<Separator />
<p class="text-sm text-muted-foreground">
Owner des Teams — diese Rolle lässt sich hier nicht ändern.
</p>
{/if}
{#if !isEdit}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={form.withAccount}
class="size-4 rounded border-input accent-primary"
/>
kann sich anmelden
</label>
{#if form.withAccount}
<div class="space-y-2">
<Label for="p-mail">E-Mail</Label>
<Input id="p-mail" type="email" bind:value={form.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="p-pw">Passwort</Label>
<Input id="p-pw" type="password" bind:value={form.password} placeholder="mindestens 8 Zeichen" />
<p class="text-xs text-muted-foreground">
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
sich unter „Einstellungen" ändern.
</p>
</div>
{/if}
{/if}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (personDialog = false)}>Abbrechen</Button>
<Button onclick={savePerson} disabled={busy}>
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Konto für einen vorhandenen Fahrer -->
<Dialog.Root bind:open={accountDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
Konto für {accountRider ? riderName(accountRider) : 'Fahrer'}
</Dialog.Title>
<Dialog.Description>
Mit einem Konto kann sich die Person selbst anmelden. Gib das Passwort
weiter; ändern lässt es sich unter „Einstellungen".
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="a-mail">E-Mail</Label>
<Input id="a-mail" type="email" bind:value={accountForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="a-pw">Passwort</Label>
<Input id="a-pw" type="password" bind:value={accountForm.password} placeholder="mindestens 8 Zeichen" />
</div>
{#if accountError}
<p class="text-sm text-destructive">{accountError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
<Button onclick={createAccount} disabled={accountBusy}>
{accountBusy ? 'Wird angelegt …' : 'Konto anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Einladungslinks: erzeugen und verwalten an einer Stelle. Die ausgegebenen
Links stehen mit im Dialog, weil sie sonst mit der eigenen Section auch das
Kopieren und das Zurückziehen verloren hätten. -->
<Dialog.Root bind:open={inviteDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Einladungslinks</Dialog.Title>
<Dialog.Description>
Wer den Link öffnet, legt sich selbst ein Konto an und steht danach im
Team {team?.name ?? ''} und im Kader. Gib ihn nur weiter, wem du das
zutraust — der Link ist das Passwort.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
{#if invites.scoped.length > 0}
<div class="space-y-2">
{#each invites.scoped as invite (invite.id)}
<div
class="flex flex-wrap items-center gap-2 rounded-lg border p-2"
class:opacity-60={!invites.isValid(invite)}
>
<input
readonly
value={invites.url(invite)}
class="min-w-0 flex-1 bg-transparent px-1 font-mono text-xs outline-none"
onfocus={(e) => e.currentTarget.select()}
/>
<span class="text-xs text-muted-foreground whitespace-nowrap">
{inviteHint(invite)}
</span>
<Button
variant="ghost"
size="icon"
class="size-8"
tooltip="Link kopieren"
onclick={() => copyLink(invite)}
>
{#if copied === invite.id}
<Check class="size-4 text-primary" />
{:else}
<Copy class="size-4" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
tooltip="Link zurückziehen"
onclick={() => removeInvite(invite)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
</div>
{/each}
</div>
<Separator />
{/if}
<div class="space-y-2">
<Label for="i-days">Gültig für (Tage)</Label>
<Input id="i-days" type="number" min="0" bind:value={inviteForm.days} />
<p class="text-xs text-muted-foreground">0 = läuft nicht ab.</p>
</div>
<div class="space-y-2">
<Label for="i-max">Höchstens einlösbar</Label>
<Input id="i-max" type="number" min="0" bind:value={inviteForm.maxUses} />
<p class="text-xs text-muted-foreground">0 = beliebig oft.</p>
</div>
{#if inviteError}
<p class="text-sm text-destructive">{inviteError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (inviteDialog = false)}>Schließen</Button>
<Button onclick={createInvite} disabled={inviteBusy}>
{inviteBusy ? 'Wird erzeugt …' : 'Erzeugen und kopieren'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Team anlegen -->
<CreateTeamDialog bind:open={createDialog} />
<!-- Team umbenennen -->
<Dialog.Root bind:open={renameDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Team umbenennen</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-rename">Name</Label>
<Input id="team-rename" bind:value={renameValue} />
</div>
{#if renameError}
<p class="text-sm text-destructive">{renameError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (renameDialog = false)}>Abbrechen</Button>
<Button onclick={doRename} disabled={!renameValue.trim()}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Logo -->
<Dialog.Root bind:open={logoDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
<Dialog.Description>
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
Logo steht dort das Wappen des Stammtischs.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="flex items-center gap-4">
<Logo team={logoTeam} class="size-20 shrink-0" />
<div class="space-y-1 min-w-0">
<Label for="team-logo">Datei</Label>
<input
id="team-logo"
type="file"
accept="image/*"
class="block w-full text-sm"
onchange={(e) => (logoFile = e.currentTarget.files?.[0] ?? null)}
/>
<p class="text-xs text-muted-foreground">PNG, SVG oder WebP, bis 5 MB.</p>
</div>
</div>
{#if logoError}
<p class="text-sm text-destructive">{logoError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (logoDialog = false)}>Abbrechen</Button>
{#if logoTeam?.logo}
<Button variant="outline" disabled={logoBusy} onclick={() => saveLogo(null)}>
Entfernen
</Button>
{/if}
<Button disabled={!logoFile || logoBusy} onclick={() => saveLogo(logoFile)}>
{logoBusy ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -148,15 +148,8 @@
</div> </div>
{/if} {/if}
<!-- <div class="flex items-center gap-4 text-sm text-muted-foreground">
Eine Zeile, dieselben Icons wie im Kopf der Trail-Seite: <!-- Dieselben Icons wie im Kopf der Trail-Seite. -->
Länge, Höhenmeter, Marker, Kommentare — und ganz rechts,
was jemanden angeht, die offenen Meldungen.
Ein Punkt markiert Bewegung der letzten zwei Wochen. Die
Zahl allein sagt nichts darüber, ob sich gerade etwas tut.
-->
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<Ruler class="size-4" /> <Ruler class="size-4" />
{formatKm(version?.distance_m)} {formatKm(version?.distance_m)}
@ -165,9 +158,27 @@
<TrendingUp class="size-4" /> <TrendingUp class="size-4" />
{version?.ascent_m ? `${version.ascent_m} hm` : ''} {version?.ascent_m ? `${version.ascent_m} hm` : ''}
</span> </span>
{#if open > 0}
<span
class="flex items-center gap-1 text-amber-600 ml-auto"
use:tooltip={{
content: `${open} offene Meldung${open === 1 ? '' : 'en'}`,
}}
>
<AlertTriangle class="size-4" />
{open}
</span>
{/if}
</div>
<!--
Ein Punkt markiert Bewegung der letzten zwei Wochen. Die
Zahl allein sagt nichts darüber, ob sich gerade etwas tut.
-->
<div class="flex items-center gap-4 text-sm border-t pt-3">
<span <span
class="flex items-center gap-1.5" class="flex items-center gap-1.5"
class:text-foreground={freshMarkers} class:text-muted-foreground={!freshMarkers}
class:font-medium={freshMarkers} class:font-medium={freshMarkers}
use:tooltip={{ use:tooltip={{
content: freshMarkers content: freshMarkers
@ -183,7 +194,7 @@
</span> </span>
<span <span
class="flex items-center gap-1.5" class="flex items-center gap-1.5"
class:text-foreground={freshComments} class:text-muted-foreground={!freshComments}
class:font-medium={freshComments} class:font-medium={freshComments}
use:tooltip={{ use:tooltip={{
content: freshComments content: freshComments
@ -197,17 +208,6 @@
<span class="size-1.5 rounded-full bg-primary"></span> <span class="size-1.5 rounded-full bg-primary"></span>
{/if} {/if}
</span> </span>
{#if open > 0}
<span
class="flex items-center gap-1 text-amber-600 ml-auto"
use:tooltip={{
content: `${open} offene Meldung${open === 1 ? '' : 'en'}`,
}}
>
<AlertTriangle class="size-4" />
{open}
</span>
{/if}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -14,7 +14,6 @@
import IconPicker from '$lib/components/IconPicker.svelte' import IconPicker from '$lib/components/IconPicker.svelte'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import { tooltip } from '$lib/stores/app.svelte' import { tooltip } from '$lib/stores/app.svelte'
import { formatDateTime, formatRelative } from '$lib/time'
import { positionAtDistance } from '$lib/gpx' import { positionAtDistance } from '$lib/gpx'
import { app } from '$lib/stores/app.svelte' import { app } from '$lib/stores/app.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -277,9 +276,9 @@
} }
// --- Hilfsfunktionen ------------------------------------------------- // --- Hilfsfunktionen -------------------------------------------------
// Dieselbe Formatierung stand hier als eigene Zeile Code und in function formatDate(s: string) {
// $lib/time; jetzt kommt sie nur noch von dort. return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
const formatDate = formatDateTime }
async function setStatus(status: string) { async function setStatus(status: string) {
if (!trail || status === trail.status) return if (!trail || status === trail.status) return
@ -369,17 +368,9 @@
</div> </div>
{#if trail.status_changed} {#if trail.status_changed}
<!--
Relativ, weil am Zustand die Frage „wie alt ist die
Meldung?" haengt und nicht „welcher Tag war das?". Das
genaue Datum steht im Tooltip.
-->
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
<span style="color: {statusInfo.color}">{statusInfo.label}</span> <span style="color: {statusInfo.color}">{statusInfo.label}</span>
gemeldet gemeldet am {formatDate(trail.status_changed)}
<span use:tooltip={{ content: formatDateTime(trail.status_changed) }}>
{formatRelative(trail.status_changed)}
</span>
{#if trail.status_by} {#if trail.status_by}
von {teams.userLabel(trail.status_by)} von {teams.userLabel(trail.status_by)}
{/if} {/if}

View file

@ -1,228 +0,0 @@
<script lang="ts">
/**
* Die Seite hinter einem Einladungslink — die einzige Seite der App, die
* ohne Login etwas tut.
*
* Sie spricht nicht mit den Collections, sondern mit den Routen aus
* backend/pb_hooks/team_invites.pb.js: Ein Nicht-Eingeloggter darf weder
* `team_invites` sehen noch ein Konto anlegen noch sich in ein Team
* eintragen. Der Hook darf all das — aber nur zusammen und nur gegen
* einen gültigen Token.
*
* Nach dem Anlegen meldet sich die Seite ganz normal mit E-Mail und
* Passwort an; es gibt keinen Sonderweg in die Anwendung.
*/
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { auth, getFileURL } from '$lib/stores/pocketbase.svelte'
import { publicInvite, type InviteTeam } from '$lib/stores/teamInvites.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Logo from '$lib/components/Logo.svelte'
import { UserPlus } from 'lucide-svelte'
import Spinner from '$lib/components/Spinner.svelte'
import { onMount } from 'svelte'
const token = $derived(page.params.token ?? '')
let loading = $state(true)
let team = $state<InviteTeam | null>(null)
let loadError = $state<string | null>(null)
let form = $state({ name: '', email: '', password: '', passwordConfirm: '' })
let busy = $state(false)
let error = $state<string | null>(null)
onMount(async () => {
try {
team = await publicInvite.load(token)
} catch (e: any) {
loadError = e.message ?? 'Diese Einladung gibt es nicht mehr.'
} finally {
loading = false
}
})
/** Das Logo des Teams — der Hook liefert den Dateinamen mit. */
const logoURL = $derived(team?.logo ? getFileURL(team, team.logo) : null)
async function submit(e: SubmitEvent) {
e.preventDefault()
if (form.password !== form.passwordConfirm) {
error = 'Die beiden Passwörter stimmen nicht überein.'
return
}
busy = true
error = null
try {
await publicInvite.accept(token, { ...form })
// Der Hook gibt bewusst kein Token zurück; die Anmeldung läuft
// durch dieselbe Tür wie jede andere.
auth.cookie = true
await auth.login(form.email, form.password)
if (auth.error) throw new Error(auth.error)
goto('/dashboard')
} catch (e: any) {
error = e.message ?? 'Das hat nicht geklappt.'
} finally {
busy = false
}
}
/** Schon angemeldet? Dann fehlt nur noch der Beitritt. */
async function join() {
busy = true
error = null
try {
await publicInvite.join(token)
goto('/dashboard')
} catch (e: any) {
error = e.message ?? 'Der Beitritt hat nicht geklappt.'
} finally {
busy = false
}
}
</script>
<svelte:head><title>Einladung</title></svelte:head>
<div class="min-h-screen flex items-center justify-center bg-background p-4">
<Card class="w-full max-w-md">
{#if loading}
<CardContent class="py-16 text-center text-muted-foreground">
<Spinner class="mx-auto mb-3 size-6" />
Einladung wird geprüft …
</CardContent>
{:else if loadError || !team}
<CardHeader class="justify-items-center gap-3 pt-2 text-center">
<Logo class="h-20 w-20" />
<CardTitle>Einladung ungültig</CardTitle>
<CardDescription>{loadError}</CardDescription>
</CardHeader>
<CardContent class="text-center">
<p class="mb-4 text-sm text-muted-foreground">
Frag im Team nach einem neuen Link — abgelaufene lassen sich nicht
wiederbeleben.
</p>
<Button variant="outline" href="/login">Zur Anmeldung</Button>
</CardContent>
{:else}
<CardHeader class="justify-items-center gap-3 pt-2 text-center">
{#if logoURL}
<img src={logoURL} alt="" class="h-20 w-20 object-contain" />
{:else}
<Logo class="h-20 w-20" />
{/if}
<CardTitle>Willkommen bei {team.name}</CardTitle>
<CardDescription>
{#if auth.user}
Du bist als {auth.user.name || auth.user.email} angemeldet.
{:else}
Leg dir ein Konto an — danach stehst du als Fahrer im Kader.
{/if}
</CardDescription>
</CardHeader>
<CardContent>
{#if auth.user}
<!--
Wer schon ein Konto hat, soll sich kein zweites
anlegen. Für ihn bleibt genau ein Knopf.
-->
<div class="space-y-4">
{#if error}
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<Button class="w-full" onclick={join} disabled={busy}>
{#if busy}
<Spinner class="mr-2 size-4" />
Tritt bei …
{:else}
<UserPlus class="mr-2 h-4 w-4" />
Team beitreten
{/if}
</Button>
<p class="text-center text-xs text-muted-foreground">
Nicht du? <a href="/login" class="underline">Mit anderem Konto anmelden</a>
</p>
</div>
{:else}
<form onsubmit={submit} class="space-y-4">
<div class="space-y-2">
<Label for="i-name">Name</Label>
<Input id="i-name" bind:value={form.name} disabled={busy} placeholder="Max Mustermann" required />
</div>
<div class="space-y-2">
<Label for="i-mail">E-Mail</Label>
<Input
id="i-mail"
type="email"
bind:value={form.email}
disabled={busy}
autocomplete="email"
placeholder="max@example.com"
required
/>
</div>
<div class="space-y-2">
<Label for="i-pw">Passwort</Label>
<Input
id="i-pw"
type="password"
bind:value={form.password}
disabled={busy}
autocomplete="new-password"
placeholder="mindestens 8 Zeichen"
minlength={8}
required
/>
</div>
<div class="space-y-2">
<Label for="i-pw2">Passwort wiederholen</Label>
<Input
id="i-pw2"
type="password"
bind:value={form.passwordConfirm}
disabled={busy}
autocomplete="new-password"
placeholder="••••••••"
required
/>
</div>
{#if error}
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<Button type="submit" class="w-full" disabled={busy}>
{#if busy}
<Spinner class="mr-2 size-4" />
Konto wird angelegt …
{:else}
Konto anlegen und beitreten
{/if}
</Button>
<p class="text-center text-xs text-muted-foreground">
Schon ein Konto? <a href="/login" class="underline">Anmelden</a> und den
Link danach erneut öffnen.
</p>
</form>
{/if}
</CardContent>
{/if}
</Card>
</div>

View file

@ -5,7 +5,7 @@
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import Spinner from '$lib/components/Spinner.svelte' import { LoaderCircle } from 'lucide-svelte'
import Logo from '$lib/components/Logo.svelte' import Logo from '$lib/components/Logo.svelte'
let email = $state('') let email = $state('')
@ -82,7 +82,7 @@
<Button type="submit" class="w-full" disabled={submitting}> <Button type="submit" class="w-full" disabled={submitting}>
{#if submitting} {#if submitting}
<Spinner class="mr-2 size-4" /> <LoaderCircle class="h-4 w-4 mr-2 animate-spin" />
Wird angemeldet… Wird angemeldet…
{:else} {:else}
Anmelden Anmelden

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 55 KiB