Compare commits
15 commits
83a1693ef1
...
0f4b532773
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f4b532773 | ||
|
|
893509be9d | ||
|
|
3120bbfdbf | ||
|
|
041bed923b | ||
|
|
b3d77dddbe | ||
|
|
66e351e54f | ||
|
|
45835cd45c | ||
|
|
8c11095b17 | ||
|
|
eac2455ee3 | ||
|
|
bb0a51378f | ||
|
|
3ba8ec4c86 | ||
|
|
70c2cdd101 | ||
|
|
c817e208cd | ||
|
|
69588528e8 | ||
|
|
7ac5e9ab4f |
48 changed files with 3446 additions and 1756 deletions
30
CLAUDE.md
30
CLAUDE.md
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## 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, runs, 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, stages, 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/`.
|
||||
|
||||
|
|
@ -60,7 +60,20 @@ docker compose down
|
|||
Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe
|
||||
`backend/README.md`.
|
||||
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
|
||||
- **Collections**: users, teams, events, runs, riders, times
|
||||
- **Collections**: users, teams, team_invites, events, stages, 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
|
||||
- **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs
|
||||
|
||||
|
|
@ -136,7 +149,18 @@ tippy.js, which the action initializes, updates and destroys.
|
|||
|
||||
### File Structure Notes
|
||||
|
||||
- Routes are in `src/routes/` following SvelteKit conventions
|
||||
- Routes are in `src/routes/` following SvelteKit conventions. **Routennamen
|
||||
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/`
|
||||
- Stores use `.svelte.ts` extension for Svelte 5 runes
|
||||
- Static assets in `static/`
|
||||
|
|
|
|||
31
NAMING.md
Normal file
31
NAMING.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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
118
TODO.md
|
|
@ -1,118 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -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
|
||||
Start automatisch angewendet. Es enthält die sechs fachlichen Collections
|
||||
`users`, `teams`, `events`, `runs`, `riders`, `times`.
|
||||
`users`, `teams`, `events`, `stages`, `riders`, `times`.
|
||||
|
||||
`1754400000_init_schema.js` ist ein Snapshot der produktiven Instanz. Er wurde
|
||||
per `GET /api/collections` abgezogen und ruft `importCollections` mit
|
||||
|
|
@ -50,6 +50,85 @@ Ohne diesen Schritt ist die Änderung beim nächsten `--build` verloren.
|
|||
Ein frisch gestarteter Container hat damit dasselbe Schema wie die produktive
|
||||
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
|
||||
|
||||
Sämtliche Laufzeitdaten liegen in der SQLite-DB unter `/pb/pb_data`: Datensätze,
|
||||
|
|
|
|||
145
backend/pb_hooks/team_invites.pb.js
Normal file
145
backend/pb_hooks/team_invites.pb.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/// <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())
|
||||
122
backend/pb_hooks/team_invites_lib.js
Normal file
122
backend/pb_hooks/team_invites_lib.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/// <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 }
|
||||
|
|
@ -588,7 +588,7 @@ migrate((app) => {
|
|||
"createRule": "team.users.id ?= @request.auth.id",
|
||||
"updateRule": "team.users.id ?= @request.auth.id",
|
||||
"deleteRule": "team.users.id ?= @request.auth.id",
|
||||
"name": "runs",
|
||||
"name": "stages",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -709,7 +709,7 @@ migrate((app) => {
|
|||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"presentable": true,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
|
|
@ -764,36 +764,6 @@ migrate((app) => {
|
|||
"system": false,
|
||||
"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,
|
||||
"collectionId": "pbc_1568971955",
|
||||
|
|
@ -946,7 +916,7 @@ migrate((app) => {
|
|||
"id": "ltfephwe",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "run",
|
||||
"name": "stage",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
|
|
@ -2105,6 +2075,138 @@ migrate((app) => {
|
|||
],
|
||||
"indexes": [],
|
||||
"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
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
99
backend/pb_migrations/1754501600_team_invites.js
Normal file
99
backend/pb_migrations/1754501600_team_invites.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/// <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'))
|
||||
})
|
||||
96
backend/pb_migrations/1754501700_rules_require_login.js
Normal file
96
backend/pb_migrations/1754501700_rules_require_login.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/// <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)
|
||||
}
|
||||
})
|
||||
87
backend/pb_migrations/1754501800_riders_single_name.js
Normal file
87
backend/pb_migrations/1754501800_riders_single_name.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/// <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)
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/// <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.
|
||||
})
|
||||
45
backend/pb_migrations/1754502000_runs_become_stages.js
Normal file
45
backend/pb_migrations/1754502000_runs_become_stages.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/// <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)
|
||||
}
|
||||
})
|
||||
|
|
@ -118,4 +118,30 @@
|
|||
body {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
frontend/src/lib/components/CreateTeamDialog.svelte
Normal file
76
frontend/src/lib/components/CreateTeamDialog.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<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>
|
||||
|
|
@ -20,9 +20,9 @@
|
|||
|
||||
<div class="relative {className}" id="logo">
|
||||
{#if url}
|
||||
<img alt="" class="absolute inset-0 h-full w-full object-contain" src={url} />
|
||||
<img alt="" class="absolute h-full w-full object-contain" src={url} />
|
||||
{:else}
|
||||
<img alt="" class="absolute inset-0 h-full w-full object-contain" src="/schildl.svg">
|
||||
<img alt="" class="absolute inset-0 h-full w-full scale-110 object-contain" src="/radl.svg">
|
||||
<img alt="" class="absolute h-full w-full object-contain" src="/schildl.svg">
|
||||
<img alt="" class="absolute h-full w-full scale-110 object-contain" src="/radl.svg">
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,17 +3,17 @@
|
|||
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
|
||||
* 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
|
||||
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Run-Seite.
|
||||
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Stage-Seite.
|
||||
*
|
||||
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
|
||||
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
|
||||
*
|
||||
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
|
||||
* Zeit-Dialog mit Fahrer und Run vorbelegt.
|
||||
* Zeit-Dialog mit Fahrer und Stage vorbelegt.
|
||||
*/
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import TimeDialog from './TimeDialog.svelte'
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
const times = getTimeContext()
|
||||
const riders = getRiderContext()
|
||||
const runs = getRunContext()
|
||||
const stages = getStageContext()
|
||||
const participants = getEventParticipantContext()
|
||||
const clock = ticker()
|
||||
|
||||
|
|
@ -35,15 +35,15 @@
|
|||
let timeDialog = $state(false)
|
||||
let dialogEvent = $state('')
|
||||
let dialogRider = $state<string | null>(null)
|
||||
let dialogRun = $state<string | null>(null)
|
||||
let dialogStage = $state<string | null>(null)
|
||||
|
||||
function openTimeDialog(riderId: string | undefined, runId: string | undefined) {
|
||||
const run = runId ? runs.getById(runId) : undefined
|
||||
if (!run?.event) return
|
||||
function openTimeDialog(riderId: string | undefined, stageId: string | undefined) {
|
||||
const stage = stageId ? stages.getById(stageId) : undefined
|
||||
if (!stage?.event) return
|
||||
|
||||
dialogEvent = run.event
|
||||
dialogEvent = stage.event
|
||||
dialogRider = riderId ?? null
|
||||
dialogRun = run.id
|
||||
dialogStage = stage.id
|
||||
timeDialog = true
|
||||
}
|
||||
|
||||
|
|
@ -51,25 +51,25 @@
|
|||
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
|
||||
const open = $derived(single || expanded)
|
||||
|
||||
/** Die Startnummer gilt je Event, deshalb der Umweg über den Run. */
|
||||
function riderLabel(riderId: string | undefined, runId: string | undefined) {
|
||||
const eventId = runId ? runs.getById(runId)?.event : undefined
|
||||
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */
|
||||
function riderLabel(riderId: string | undefined, stageId: string | undefined) {
|
||||
const eventId = stageId ? stages.getById(stageId)?.event : undefined
|
||||
if (!eventId) {
|
||||
const r = riderId ? riders.getById(riderId) : undefined
|
||||
return r ? fullName(r) : 'Unbekannter Fahrer'
|
||||
return r ? riderName(r) : 'Unbekannter Fahrer'
|
||||
}
|
||||
return participants.riderLabel(eventId, riderId)
|
||||
}
|
||||
|
||||
function runPath(runId: string | undefined) {
|
||||
const run = runId ? runs.getById(runId) : undefined
|
||||
if (!run?.event) return null
|
||||
return `/dashboard/events/${run.event}/runs/${run.id}`
|
||||
function stagePath(stageId: string | undefined) {
|
||||
const stage = stageId ? stages.getById(stageId) : undefined
|
||||
if (!stage?.event) return null
|
||||
return `/dashboard/events/${stage.event}/stages/${stage.id}`
|
||||
}
|
||||
|
||||
function runLabel(runId: string | undefined) {
|
||||
const run = runId ? runs.getById(runId) : undefined
|
||||
return run?.description || 'Run'
|
||||
function stageLabel(stageId: string | undefined) {
|
||||
const stage = stageId ? stages.getById(stageId) : undefined
|
||||
return stage?.description || 'Stage'
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -113,15 +113,15 @@
|
|||
{#if open}
|
||||
<ul class="divide-y max-h-[50vh] overflow-y-auto">
|
||||
{#each running as t (t.id)}
|
||||
{@const path = runPath(t.run)}
|
||||
{@const path = stagePath(t.stage)}
|
||||
<li class="flex items-center gap-2 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{riderLabel(t.rider, t.run)}</p>
|
||||
<p class="text-sm font-medium truncate">{riderLabel(t.rider, t.stage)}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{#if path}
|
||||
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
|
||||
<a href={path} class="hover:underline">{stageLabel(t.stage)}</a>
|
||||
{:else}
|
||||
{runLabel(t.run)}
|
||||
{stageLabel(t.stage)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
size="icon"
|
||||
class="shrink-0"
|
||||
tooltip="Zeit erfassen"
|
||||
onclick={() => openTimeDialog(t.rider, t.run)}
|
||||
onclick={() => openTimeDialog(t.rider, t.stage)}
|
||||
>
|
||||
<Timer class="size-4" />
|
||||
</Button>
|
||||
|
|
@ -150,6 +150,6 @@
|
|||
bind:open={timeDialog}
|
||||
eventId={dialogEvent}
|
||||
riderId={dialogRider}
|
||||
runId={dialogRun}
|
||||
stageId={dialogStage}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
39
frontend/src/lib/components/Spinner.svelte
Normal file
39
frontend/src/lib/components/Spinner.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<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>
|
||||
137
frontend/src/lib/components/TeamMenu.svelte
Normal file
137
frontend/src/lib/components/TeamMenu.svelte
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<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} />
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Der eine Dialog für die Zeitnahme. Fahrer und Run stehen immer beide
|
||||
* Der eine Dialog für die Zeitnahme. Fahrer und Stage stehen immer beide
|
||||
* darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit
|
||||
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
|
||||
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
|
||||
* Tabellen noch die Toast-Leiste lösen sie direkt aus.
|
||||
*/
|
||||
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -21,24 +21,24 @@
|
|||
open = $bindable(false),
|
||||
eventId,
|
||||
riderId = null,
|
||||
runId = null,
|
||||
stageId = null,
|
||||
}: {
|
||||
open?: boolean
|
||||
/** Event, aus dem Fahrer und Runs zur Auswahl stehen. */
|
||||
/** Event, aus dem Fahrer und Stages zur Auswahl stehen. */
|
||||
eventId: string
|
||||
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
|
||||
riderId?: string | null
|
||||
runId?: string | null
|
||||
stageId?: string | null
|
||||
} = $props()
|
||||
|
||||
const times = getTimeContext()
|
||||
const riders = getRiderContext()
|
||||
const runs = getRunContext()
|
||||
const stages = getStageContext()
|
||||
const participants = getEventParticipantContext()
|
||||
const clock = ticker()
|
||||
|
||||
let selectedRider = $state('')
|
||||
let selectedRun = $state('')
|
||||
let selectedStage = $state('')
|
||||
let error = $state<string | null>(null)
|
||||
let confirmDnf = $state(false)
|
||||
let busy = $state(false)
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
if (!open) return
|
||||
|
||||
selectedRider = riderId ?? ''
|
||||
selectedRun = runId ?? ''
|
||||
selectedStage = stageId ?? ''
|
||||
error = null
|
||||
confirmDnf = false
|
||||
})
|
||||
|
|
@ -63,16 +63,16 @@
|
|||
.filter((r) => !!r)
|
||||
: [],
|
||||
)
|
||||
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
|
||||
const eventStages = $derived(eventId ? stages.getByEvent(eventId) : [])
|
||||
|
||||
const riderLabel = $derived(
|
||||
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
|
||||
)
|
||||
const runLabel = $derived(
|
||||
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
|
||||
const stageLabel = $derived(
|
||||
selectedStage ? stages.getById(selectedStage)?.description ?? 'Stage wählen…' : 'Stage wählen…',
|
||||
)
|
||||
|
||||
const ready = $derived(!!selectedRider && !!selectedRun)
|
||||
const ready = $derived(!!selectedRider && !!selectedStage)
|
||||
|
||||
// Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und
|
||||
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
|
||||
|
|
@ -80,14 +80,14 @@
|
|||
ready
|
||||
? times
|
||||
.allRunning(clock.now)
|
||||
.find((t) => t.run === selectedRun && t.rider === selectedRider)
|
||||
.find((t) => t.stage === selectedStage && t.rider === selectedRider)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
|
||||
const existing = $derived(
|
||||
ready && !active
|
||||
? times.getByRun(selectedRun).find((t) => t.rider === selectedRider && t.stop)
|
||||
? times.getByStage(selectedStage).find((t) => t.rider === selectedRider && t.stop)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
busy = true
|
||||
error = null
|
||||
try {
|
||||
await times.start(selectedRun, selectedRider)
|
||||
await times.start(selectedStage, selectedRider)
|
||||
open = false
|
||||
} catch (e: any) {
|
||||
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
<Dialog.Header>
|
||||
<Dialog.Title>Zeit erfassen</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Fahrer und Run wählen — je nach Stand wird gestartet oder gestoppt.
|
||||
Fahrer und Stage wählen — je nach Stand wird gestartet oder gestoppt.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
|
|
@ -151,17 +151,17 @@
|
|||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Run</Label>
|
||||
<Select.Root type="single" bind:value={selectedRun} disabled={eventRuns.length === 0}>
|
||||
<Select.Trigger class="w-full">{runLabel}</Select.Trigger>
|
||||
<Label>Stage</Label>
|
||||
<Select.Root type="single" bind:value={selectedStage} disabled={eventStages.length === 0}>
|
||||
<Select.Trigger class="w-full">{stageLabel}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each eventRuns as r (r.id)}
|
||||
<Select.Item value={r.id}>{r.description || 'Run'}</Select.Item>
|
||||
{#each eventStages as r (r.id)}
|
||||
<Select.Item value={r.id}>{r.description || 'Stage'}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if eventRuns.length === 0}
|
||||
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Runs.</p>
|
||||
{#if eventStages.length === 0}
|
||||
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Stages.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@
|
|||
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx'
|
||||
import FlagIcon from './FlagIcon.svelte'
|
||||
import { trailStatusColor } from '$lib/trailStatus'
|
||||
import { env } from '$env/dynamic/public'
|
||||
import { mode } from 'mode-watcher'
|
||||
|
||||
maplibregl.setWorkerUrl(workerUrl)
|
||||
|
|
@ -84,29 +83,24 @@
|
|||
} = $props()
|
||||
|
||||
/**
|
||||
* Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen
|
||||
* keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt
|
||||
* dadurch deutlich sichtbarer darüber — und es gibt sie in einer echten
|
||||
* dunklen Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein
|
||||
* dunkleres Bild, keine dunkle Karte.
|
||||
* Basiskarten von OpenFreeMap: zurückhaltend gezeichnet — die Trail-Linie
|
||||
* liegt dadurch deutlich sichtbarer darüber — und in einer echten dunklen
|
||||
* Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein dunkleres
|
||||
* Bild, keine dunkle Karte.
|
||||
*
|
||||
* Mehrere Subdomains, weil Browser die Verbindungen pro Host begrenzen.
|
||||
* Vorher lagen hier die Rasterkacheln von CARTO. Die kommen inzwischen
|
||||
* 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.
|
||||
*/
|
||||
// Der Schlüssel muss den Browser erreichen — er kommt deshalb aus den
|
||||
// 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 = '© OpenStreetMap-Mitwirkende, © CARTO'
|
||||
const STYLE_LIGHT = 'https://tiles.openfreemap.org/styles/positron'
|
||||
const STYLE_DARK = 'https://tiles.openfreemap.org/styles/dark'
|
||||
|
||||
const isDark = $derived(mode.current === 'dark')
|
||||
|
||||
|
|
@ -143,18 +137,7 @@
|
|||
function build() {
|
||||
const instance = new maplibregl.Map({
|
||||
container,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: isDark ? TILES_DARK : TILES_LIGHT,
|
||||
tileSize: 256,
|
||||
attribution: ATTRIBUTION,
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
||||
},
|
||||
style: isDark ? STYLE_DARK : STYLE_LIGHT,
|
||||
center: FALLBACK_CENTER,
|
||||
zoom: 11,
|
||||
attributionControl: controls ? undefined : false,
|
||||
|
|
@ -166,8 +149,13 @@
|
|||
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
|
||||
}
|
||||
|
||||
instance.on('load', () => {
|
||||
// `style.load` statt `load`: Es feuert auch nach jedem
|
||||
// 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
|
||||
drawTrail(instance)
|
||||
})
|
||||
|
||||
cleanupMap = () => {
|
||||
|
|
@ -252,18 +240,26 @@
|
|||
})
|
||||
}
|
||||
|
||||
// Trail-Linie zeichnen bzw. aktualisieren
|
||||
$effect(() => {
|
||||
if (!map || !ready) return
|
||||
/**
|
||||
* Die layerbezogenen Listener bleiben am Kartenobjekt haengen, auch wenn
|
||||
* ihr Layer beim Stilwechsel verschwindet und gleich darauf wieder
|
||||
* 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 color = trailStatusColor(status)
|
||||
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
|
||||
const src = instance.getSource('trail') as maplibregl.GeoJSONSource | undefined
|
||||
|
||||
if (!data) {
|
||||
if (src) {
|
||||
src.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
src?.setData({ type: 'FeatureCollection', features: [] })
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -271,33 +267,46 @@
|
|||
|
||||
if (src) {
|
||||
src.setData(feature)
|
||||
map.setPaintProperty('trail-line', 'line-color', color)
|
||||
} else {
|
||||
map.addSource('trail', { type: 'geojson', data: feature })
|
||||
map.addLayer({
|
||||
instance.setPaintProperty('trail-line', 'line-color', color)
|
||||
return
|
||||
}
|
||||
|
||||
instance.addSource('trail', { type: 'geojson', data: feature })
|
||||
instance.addLayer({
|
||||
id: 'trail-line',
|
||||
type: 'line',
|
||||
source: 'trail',
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: { 'line-color': color, 'line-width': 4 },
|
||||
})
|
||||
map.addLayer({
|
||||
instance.addLayer({
|
||||
id: 'trail-hit',
|
||||
type: 'line',
|
||||
source: 'trail',
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 },
|
||||
})
|
||||
bindTrailEvents(map)
|
||||
}
|
||||
})
|
||||
|
||||
/** Beim Themenwechsel die Kachelquelle austauschen. */
|
||||
if (!trailEventsBound) {
|
||||
bindTrailEvents(instance)
|
||||
trailEventsBound = true
|
||||
}
|
||||
}
|
||||
|
||||
// Trail-Linie zeichnen bzw. aktualisieren
|
||||
$effect(() => {
|
||||
if (!map || !ready) return
|
||||
drawTrail(map)
|
||||
})
|
||||
|
||||
const src = map.getSource('osm') as maplibregl.RasterTileSource | undefined
|
||||
src?.setTiles(isDark ? TILES_DARK : TILES_LIGHT)
|
||||
/**
|
||||
* 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(() => {
|
||||
if (!map || !ready) return
|
||||
map.setStyle(isDark ? STYLE_DARK : STYLE_LIGHT)
|
||||
})
|
||||
|
||||
// Kartenausschnitt auf den Track setzen
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { getContext, setContext } from 'svelte'
|
||||
import { api, auth } from './pocketbase.svelte'
|
||||
import { getEventContext } from './events.svelte'
|
||||
import { getRiderContext } from './riders.svelte'
|
||||
import { getRiderContext, riderName } from './riders.svelte'
|
||||
import type { EventParticipantsResponse, RidersResponse } from '$lib/types'
|
||||
|
||||
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)) return -1
|
||||
if (Number.isFinite(nb)) return 1
|
||||
return this.riderName(a.rider).localeCompare(this.riderName(b.rider))
|
||||
return this.nameOf(a.rider).localeCompare(this.nameOf(b.rider))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -94,16 +94,15 @@ export class EventParticipantStore {
|
|||
riderLabel(eventId: string, riderId: string | undefined): string {
|
||||
if (!riderId) return 'Unbekannter Fahrer'
|
||||
|
||||
const name = this.riderName(riderId) || 'Unbekannter Fahrer'
|
||||
const name = this.nameOf(riderId) || 'Unbekannter Fahrer'
|
||||
const number = this.numberOf(eventId, riderId)
|
||||
|
||||
return number ? `#${number} ${name}` : name
|
||||
}
|
||||
|
||||
private riderName(riderId: string): string {
|
||||
private nameOf(riderId: string): string {
|
||||
const r = this.riders.getById(riderId)
|
||||
if (!r) return ''
|
||||
return [r.firstname, r.lastname].filter(Boolean).join(' ') || r.name || ''
|
||||
return r ? riderName(r) : ''
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
|
|
|||
|
|
@ -27,19 +27,19 @@ class AuthStore {
|
|||
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) => {
|
||||
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
|
||||
|
||||
if (this.error) {
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.collection('users').authWithPassword(auth.username, auth.password, this.params)
|
||||
await api.collection('users').authWithPassword(user, password, this.params)
|
||||
|
||||
this.user = api.authStore.record as AuthRecord | null
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,20 @@ const KEY = Symbol('rider')
|
|||
* Die gelten seit 1754501300 je Event und stehen am Teilnehmer.
|
||||
*/
|
||||
function sortByName(records: RidersResponse[]) {
|
||||
return [...records].sort((a, b) => fullName(a).localeCompare(fullName(b)))
|
||||
return [...records].sort((a, b) => riderName(a).localeCompare(riderName(b)))
|
||||
}
|
||||
|
||||
export function fullName(r: RidersResponse) {
|
||||
const parts = [r.firstname, r.lastname].filter(Boolean)
|
||||
return parts.join(' ').trim() || r.name || ''
|
||||
/**
|
||||
* Der Name eines Fahrers — ein Feld, ein Wert.
|
||||
*
|
||||
* 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 {
|
||||
|
|
@ -84,13 +92,47 @@ export class RiderStore {
|
|||
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) {
|
||||
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) => {
|
||||
app.confirm.request({
|
||||
title: 'Fahrer löschen?',
|
||||
text: `Möchten Sie „${fullName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`,
|
||||
text,
|
||||
yes: async () => {
|
||||
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)
|
||||
app.confirm.close()
|
||||
resolve()
|
||||
|
|
@ -116,9 +158,14 @@ export class RiderStore {
|
|||
return this.scoped.filter((r) => r.user === userId)
|
||||
}
|
||||
|
||||
/** Bestehendes Konto zuordnen oder die Zuordnung lösen. */
|
||||
async linkUser(riderId: string, userId: string | null) {
|
||||
return await this.edit(riderId, { user: userId ?? '' } as Partial<RidersResponse>)
|
||||
/**
|
||||
* Konto an den Fahrer hängen. Nur intern und nur in diese Richtung: Ein
|
||||
* Konto entsteht am Fahrer, ein Lösen der Verbindung gibt es nicht — sonst
|
||||
* 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>)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -131,7 +178,7 @@ export class RiderStore {
|
|||
): Promise<UsersResponse> {
|
||||
const user = await this.teams.createMember({
|
||||
...data,
|
||||
name: data.name?.trim() || fullName(rider),
|
||||
name: data.name?.trim() || riderName(rider),
|
||||
})
|
||||
|
||||
await this.linkUser(rider.id, user.id)
|
||||
|
|
@ -142,13 +189,7 @@ export class RiderStore {
|
|||
get filtered() {
|
||||
const q = this.searchQuery.toLowerCase().trim()
|
||||
if (!q) return this.scoped
|
||||
return this.scoped.filter((r) => {
|
||||
return (
|
||||
(r.firstname ?? '').toLowerCase().includes(q) ||
|
||||
(r.lastname ?? '').toLowerCase().includes(q) ||
|
||||
(r.name ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
return this.scoped.filter((r) => riderName(r).toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@ import { getContext, setContext } from 'svelte'
|
|||
import { api } from './pocketbase.svelte'
|
||||
import { app } from './app.svelte'
|
||||
import { getTeamContext } from './teams.svelte'
|
||||
import type { RunsResponse } from '$lib/types'
|
||||
import type { StagesResponse } from '$lib/types'
|
||||
|
||||
const KEY = Symbol('run')
|
||||
const KEY = Symbol('stage')
|
||||
|
||||
export class RunStore {
|
||||
records = $state<RunsResponse[]>([])
|
||||
export class StageStore {
|
||||
records = $state<StagesResponse[]>([])
|
||||
loading = $state(false)
|
||||
error = $state<string | null>(null)
|
||||
|
||||
private unsubscribe: (() => void) | null = null
|
||||
private teams = getTeamContext()
|
||||
|
||||
get scoped(): RunsResponse[] {
|
||||
get scoped(): StagesResponse[] {
|
||||
const teamId = this.teams.activeId
|
||||
if (!teamId) return []
|
||||
return this.records.filter((r) => r.team === teamId)
|
||||
|
|
@ -24,12 +24,12 @@ export class RunStore {
|
|||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
this.records = await api.collection('runs').getFullList({
|
||||
this.records = await api.collection('stages').getFullList({
|
||||
sort: '+created',
|
||||
requestKey: null,
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.error = e.message ?? 'Fehler beim Laden der Runs'
|
||||
this.error = e.message ?? 'Fehler beim Laden der Stages'
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
|
|
@ -38,34 +38,34 @@ export class RunStore {
|
|||
|
||||
subscribe() {
|
||||
if (this.unsubscribe) return
|
||||
api.collection('runs').subscribe('*', (e) => {
|
||||
api.collection('stages').subscribe('*', (e) => {
|
||||
const idx = this.records.findIndex((r) => r.id === e.record.id)
|
||||
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record]
|
||||
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
|
||||
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
|
||||
}).then((unsub) => {
|
||||
this.unsubscribe = unsub
|
||||
}).catch((e) => console.warn('runs subscribe failed:', e))
|
||||
}).catch((e) => console.warn('stages subscribe failed:', e))
|
||||
}
|
||||
|
||||
async create(data: Partial<RunsResponse>) {
|
||||
async create(data: Partial<StagesResponse>) {
|
||||
const teamId = this.teams.activeId
|
||||
if (!teamId) throw new Error('Kein aktives Team')
|
||||
return await api.collection('runs').create({ ...data, team: teamId })
|
||||
return await api.collection('stages').create({ ...data, team: teamId })
|
||||
}
|
||||
|
||||
async edit(id: string, data: Partial<RunsResponse>) {
|
||||
return await api.collection('runs').update(id, data)
|
||||
async edit(id: string, data: Partial<StagesResponse>) {
|
||||
return await api.collection('stages').update(id, data)
|
||||
}
|
||||
|
||||
remove(record: RunsResponse) {
|
||||
remove(record: StagesResponse) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
app.confirm.request({
|
||||
title: 'Run löschen?',
|
||||
title: 'Stage löschen?',
|
||||
text: `Möchten Sie „${record.description}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`,
|
||||
yes: async () => {
|
||||
try {
|
||||
await api.collection('runs').delete(record.id)
|
||||
await api.collection('stages').delete(record.id)
|
||||
app.confirm.close()
|
||||
resolve()
|
||||
} catch (e) {
|
||||
|
|
@ -97,12 +97,12 @@ export class RunStore {
|
|||
}
|
||||
}
|
||||
|
||||
export function setRunContext() {
|
||||
const store = new RunStore()
|
||||
export function setStageContext() {
|
||||
const store = new StageStore()
|
||||
setContext(KEY, store)
|
||||
return store
|
||||
}
|
||||
|
||||
export function getRunContext(): RunStore {
|
||||
return getContext<RunStore>(KEY)
|
||||
export function getStageContext(): StageStore {
|
||||
return getContext<StageStore>(KEY)
|
||||
}
|
||||
193
frontend/src/lib/stores/teamInvites.svelte.ts
Normal file
193
frontend/src/lib/stores/teamInvites.svelte.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -246,19 +246,6 @@ export class TeamStore {
|
|||
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() {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe()
|
||||
|
|
|
|||
|
|
@ -75,11 +75,11 @@ export class TimeStore {
|
|||
}).catch((e) => console.warn('times subscribe failed:', e))
|
||||
}
|
||||
|
||||
async start(runId: string, riderId: string) {
|
||||
async start(stageId: string, riderId: string) {
|
||||
const teamId = this.teams.activeId
|
||||
if (!teamId) throw new Error('Kein aktives Team')
|
||||
return await api.collection('times').create({
|
||||
run: runId,
|
||||
stage: stageId,
|
||||
rider: riderId,
|
||||
team: teamId,
|
||||
start: new Date().toISOString(),
|
||||
|
|
@ -128,17 +128,17 @@ export class TimeStore {
|
|||
})
|
||||
}
|
||||
|
||||
getByRun(runId: string, now = Date.now()): TimeWithDuration[] {
|
||||
return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now))
|
||||
getByStage(stageId: string, now = Date.now()): TimeWithDuration[] {
|
||||
return this.scoped.filter((r) => r.stage === stageId).map((r) => enrich(r, now))
|
||||
}
|
||||
|
||||
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
|
||||
return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now))
|
||||
}
|
||||
|
||||
/** Laufende Zeiten eines Runs — der am längsten laufende zuerst. */
|
||||
runningByRun(runId: string, now = Date.now()): TimeWithDuration[] {
|
||||
return this.getByRun(runId, now)
|
||||
/** Laufende Zeiten einer Stage — die am längsten laufende zuerst. */
|
||||
runningByStage(stageId: string, now = Date.now()): TimeWithDuration[] {
|
||||
return this.getByStage(stageId, now)
|
||||
.filter((r) => r.status === 'active')
|
||||
.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,
|
||||
* dürfen aber auch nicht unsichtbar werden.
|
||||
*/
|
||||
unrankedByRun(runId: string): TimeWithDuration[] {
|
||||
return this.getByRun(runId).filter(
|
||||
unrankedByStage(stageId: string): TimeWithDuration[] {
|
||||
return this.getByStage(stageId).filter(
|
||||
(r) => r.status !== 'active' && r.status !== 'finished',
|
||||
)
|
||||
}
|
||||
|
||||
isRiderActive(runId: string, riderId: string) {
|
||||
isRiderActive(stageId: string, riderId: string) {
|
||||
return this.scoped.some(
|
||||
(r) => r.run === runId && r.rider === riderId && r.status === 'active',
|
||||
(r) => r.stage === stageId && r.rider === riderId && r.status === 'active',
|
||||
)
|
||||
}
|
||||
|
||||
getActiveTime(runId: string, riderId: string) {
|
||||
getActiveTime(stageId: string, riderId: string) {
|
||||
return this.scoped.find(
|
||||
(r) => r.run === runId && r.rider === riderId && r.status === 'active',
|
||||
(r) => r.stage === stageId && r.rider === riderId && r.status === 'active',
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -170,8 +170,8 @@ export class TimeStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Alle laufenden Zeiten des Teams, run-übergreifend — der am längsten
|
||||
* laufende zuerst. `now` wie bei `getByRun`, damit die Anzeige mitzählt.
|
||||
* Alle laufenden Zeiten des Teams, stage-übergreifend — der am längsten
|
||||
* laufende zuerst. `now` wie bei `getByStage`, damit die Anzeige mitzählt.
|
||||
*/
|
||||
allRunning(now = Date.now()): TimeWithDuration[] {
|
||||
return this.scoped
|
||||
|
|
@ -185,15 +185,15 @@ export class TimeStore {
|
|||
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
|
||||
}
|
||||
|
||||
getLeaderboard(runId: string): TimeWithDuration[] {
|
||||
return this.getByRun(runId)
|
||||
getLeaderboard(stageId: string): TimeWithDuration[] {
|
||||
return this.getByStage(stageId)
|
||||
.filter((r) => r.status === 'finished' && r.duration !== undefined)
|
||||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
|
||||
}
|
||||
|
||||
/** Platz eines Fahrers im Run, 1-basiert; null ohne gewertete Zeit. */
|
||||
placement(runId: string, riderId: string): number | null {
|
||||
const idx = this.getLeaderboard(runId).findIndex((t) => t.rider === riderId)
|
||||
/** Platz eines Fahrers im Stage, 1-basiert; null ohne gewertete Zeit. */
|
||||
placement(stageId: string, riderId: string): number | null {
|
||||
const idx = this.getLeaderboard(stageId).findIndex((t) => t.rider === riderId)
|
||||
return idx === -1 ? null : idx + 1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
/**
|
||||
* Formatierung von Zeiten. Bewusst hier und nicht im times-Store: Auch
|
||||
* Komponenten ohne Store-Zugriff zeigen Dauern an, und die Darstellung soll
|
||||
|
|
@ -23,10 +26,39 @@ export const TIME_STATUS_LABEL: Record<string, string> = {
|
|||
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`. */
|
||||
export function formatDateTime(iso: string | undefined): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
const date = parseDate(iso)
|
||||
if (!date) return '—'
|
||||
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). */
|
||||
|
|
|
|||
33
frontend/src/lib/types.d.ts
vendored
33
frontend/src/lib/types.d.ts
vendored
|
|
@ -10,7 +10,8 @@ export enum Collections {
|
|||
EventSeries = "event_series",
|
||||
Events = "events",
|
||||
Riders = "riders",
|
||||
Runs = "runs",
|
||||
Stages = "stages",
|
||||
TeamInvites = "team_invites",
|
||||
Teams = "teams",
|
||||
Times = "times",
|
||||
TrailComments = "trail_comments",
|
||||
|
|
@ -116,16 +117,14 @@ export type EventsRecord = {
|
|||
export type RidersRecord = {
|
||||
avatar?: string
|
||||
created?: IsoDateString
|
||||
firstname?: string
|
||||
id: string
|
||||
lastname?: string
|
||||
name?: string
|
||||
team?: RecordIdString
|
||||
updated?: IsoDateString
|
||||
user?: RecordIdString
|
||||
}
|
||||
|
||||
export type RunsRecord = {
|
||||
export type StagesRecord = {
|
||||
created?: IsoDateString
|
||||
description?: string
|
||||
event?: RecordIdString
|
||||
|
|
@ -134,6 +133,18 @@ export type RunsRecord = {
|
|||
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 = {
|
||||
admins?: RecordIdString[]
|
||||
created?: IsoDateString
|
||||
|
|
@ -158,7 +169,7 @@ export type TimesRecord = {
|
|||
created?: IsoDateString
|
||||
id: string
|
||||
rider?: RecordIdString
|
||||
run?: RecordIdString
|
||||
stage?: RecordIdString
|
||||
start?: IsoDateString
|
||||
startedBy?: RecordIdString
|
||||
status?: TimesStatusOptions
|
||||
|
|
@ -267,7 +278,8 @@ export type EventParticipantsResponse<Texpand = unknown> = Required<EventPartici
|
|||
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
|
||||
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
||||
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
|
||||
export type RunsResponse<Texpand = unknown> = Required<RunsRecord> & BaseSystemFields<Texpand>
|
||||
export type StagesResponse<Texpand = unknown> = Required<StagesRecord> & BaseSystemFields<Texpand>
|
||||
export type TeamInvitesResponse<Texpand = unknown> = Required<TeamInvitesRecord> & BaseSystemFields<Texpand>
|
||||
export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
|
||||
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand>
|
||||
export type TrailCommentsResponse<Texpand = unknown> = Required<TrailCommentsRecord> & BaseSystemFields<Texpand>
|
||||
|
|
@ -284,7 +296,8 @@ export type CollectionRecords = {
|
|||
event_series: EventSeriesRecord
|
||||
events: EventsRecord
|
||||
riders: RidersRecord
|
||||
runs: RunsRecord
|
||||
stages: StagesRecord
|
||||
team_invites: TeamInvitesRecord
|
||||
teams: TeamsRecord
|
||||
times: TimesRecord
|
||||
trail_comments: TrailCommentsRecord
|
||||
|
|
@ -300,7 +313,8 @@ export type CollectionResponses = {
|
|||
event_series: EventSeriesResponse
|
||||
events: EventsResponse
|
||||
riders: RidersResponse
|
||||
runs: RunsResponse
|
||||
stages: StagesResponse
|
||||
team_invites: TeamInvitesResponse
|
||||
teams: TeamsResponse
|
||||
times: TimesResponse
|
||||
trail_comments: TrailCommentsResponse
|
||||
|
|
@ -319,7 +333,8 @@ export type TypedPocketBase = PocketBase & {
|
|||
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
||||
collection(idOrName: 'events'): RecordService<EventsResponse>
|
||||
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
||||
collection(idOrName: 'runs'): RecordService<RunsResponse>
|
||||
collection(idOrName: 'stages'): RecordService<StagesResponse>
|
||||
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
|
||||
collection(idOrName: 'teams'): RecordService<TeamsResponse>
|
||||
collection(idOrName: 'times'): RecordService<TimesResponse>
|
||||
collection(idOrName: 'trail_comments'): RecordService<TrailCommentsResponse>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import {auth} from '$lib/stores/pocketbase.svelte'
|
||||
import {goto} from '$app/navigation'
|
||||
import {onMount} from 'svelte'
|
||||
import {LoaderCircle} from 'lucide-svelte'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
|
||||
onMount(() => {
|
||||
goto(auth.user ? '/dashboard' : '/login', {replaceState: true})
|
||||
|
|
@ -10,5 +10,5 @@
|
|||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<LoaderCircle class="animate-spin" size="64"/>
|
||||
<Spinner class="size-16"/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/pocketbase.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { app, tooltip } from '$lib/stores/app.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { tooltip } from '$lib/stores/app.svelte'
|
||||
import { setTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { setTeamInviteContext } from '$lib/stores/teamInvites.svelte'
|
||||
import { setEventContext } from '$lib/stores/events.svelte'
|
||||
import { setRunContext } from '$lib/stores/runs.svelte'
|
||||
import { setStageContext } from '$lib/stores/stages.svelte'
|
||||
import { setRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { setTimeContext } from '$lib/stores/times.svelte'
|
||||
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
|
|
@ -17,10 +19,10 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import Logo from '$lib/components/Logo.svelte'
|
||||
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
|
||||
import TeamMenu from '$lib/components/TeamMenu.svelte'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
||||
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte'
|
||||
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Users } from 'lucide-svelte'
|
||||
import { toggleMode, mode } from 'mode-watcher'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
|
|
@ -29,8 +31,9 @@
|
|||
// Teams MUSS vor den anderen Stores stehen, weil die anderen Stores
|
||||
// getTeamContext() im Constructor aufrufen
|
||||
const teams = setTeamContext()
|
||||
const invites = setTeamInviteContext()
|
||||
const events = setEventContext()
|
||||
const runs = setRunContext()
|
||||
const stages = setStageContext()
|
||||
const riders = setRiderContext()
|
||||
const times = setTimeContext()
|
||||
// Nach events und riders: Der Store greift im Constructor auf beide zu.
|
||||
|
|
@ -53,12 +56,14 @@
|
|||
await teams.load()
|
||||
teams.subscribe()
|
||||
await Promise.all([
|
||||
events.load(), runs.load(), riders.load(), times.load(), participants.load(), series.load(),
|
||||
events.load(), stages.load(), riders.load(), times.load(), participants.load(), series.load(),
|
||||
invites.load(),
|
||||
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
|
||||
trailComments.load(),
|
||||
])
|
||||
invites.subscribe()
|
||||
events.subscribe()
|
||||
runs.subscribe()
|
||||
stages.subscribe()
|
||||
riders.subscribe()
|
||||
times.subscribe()
|
||||
participants.subscribe()
|
||||
|
|
@ -72,8 +77,9 @@
|
|||
|
||||
onDestroy(() => {
|
||||
teams.destroy()
|
||||
invites.destroy()
|
||||
events.destroy()
|
||||
runs.destroy()
|
||||
stages.destroy()
|
||||
riders.destroy()
|
||||
times.destroy()
|
||||
participants.destroy()
|
||||
|
|
@ -85,25 +91,54 @@
|
|||
trailComments.destroy()
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
app.confirm.request({
|
||||
title: 'Abmelden?',
|
||||
text: 'Möchten Sie sich wirklich abmelden?',
|
||||
yes: async () => {
|
||||
// Ohne Rückfrage: Abmelden löscht nichts und ist mit einem Login wieder
|
||||
// rückgängig gemacht — eine Bestätigung wäre nur ein zusätzlicher Klick.
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
app.confirm.close()
|
||||
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
|
||||
// passiert dort, wo es hingehört — am Run innerhalb seines Events.
|
||||
// passiert dort, wo es hingehört — an der Stage innerhalb ihres Events.
|
||||
const nav = [
|
||||
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
||||
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
||||
{ href: '/dashboard/riders', icon: Bike, label: 'Fahrer' },
|
||||
{ href: '/dashboard/team', icon: Users, label: 'Team', submenu: true },
|
||||
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
|
||||
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
|
||||
]
|
||||
|
|
@ -121,38 +156,54 @@
|
|||
aria-hidden="true"
|
||||
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" />
|
||||
</div>
|
||||
|
||||
<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="flex min-h-16 items-center justify-between gap-4 py-2">
|
||||
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
|
||||
voller Groesse wuerde es die 64 px hohe Leiste
|
||||
ueberragen und beschnitten wirken. -->
|
||||
<!-- Links: Logo und Menü. Das negative -my-3 frisst das
|
||||
py-2 der Leiste auf und noch etwas mehr: Das Logo
|
||||
steht dadurch 72 px hoch in einer 64 px hohen Leiste,
|
||||
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">
|
||||
<Logo team={teams.active} class="h-11 w-11 shrink-0" />
|
||||
<a
|
||||
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-1">
|
||||
<nav class="hidden md:flex items-center gap-2">
|
||||
{#each nav as item}
|
||||
{@const Icon = item.icon}
|
||||
{#if item.submenu}
|
||||
<TeamMenu class={navClass(item.href)} />
|
||||
{:else}
|
||||
<a
|
||||
href={item.href}
|
||||
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"
|
||||
class={navClass(item.href, dashHover && item.href === '/dashboard')}
|
||||
onmouseenter={() => item.href === '/dashboard' && (dashHover = true)}
|
||||
onmouseleave={() => item.href === '/dashboard' && (dashHover = false)}
|
||||
>
|
||||
<Icon class="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
|
||||
Zeile, das Team-Dropdown darunter. -->
|
||||
<!-- Rechts: wer man ist, und was man mit sich tun kann.
|
||||
Das Team steht jetzt im Menue links. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 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">
|
||||
<div class="hidden md:flex min-w-0 items-center gap-3">
|
||||
<a
|
||||
href="/dashboard/settings"
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
|
|
@ -190,11 +241,6 @@
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<TeamSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -217,14 +263,21 @@
|
|||
<nav class="px-2 py-2 space-y-1">
|
||||
{#each nav as item}
|
||||
{@const Icon = item.icon}
|
||||
{#if item.submenu}
|
||||
<TeamMenu
|
||||
class="w-full {navClass(item.href)}"
|
||||
onnavigate={() => (isMobileMenuOpen = false)}
|
||||
/>
|
||||
{:else}
|
||||
<a
|
||||
href={item.href}
|
||||
onclick={() => (isMobileMenuOpen = false)}
|
||||
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"
|
||||
class="w-full {navClass(item.href)}"
|
||||
>
|
||||
<Icon class="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
<Separator class="my-2" />
|
||||
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
|
|
@ -235,7 +288,6 @@
|
|||
{auth.user.name || auth.user.email}
|
||||
</span>
|
||||
</span>
|
||||
<TeamSwitcher />
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
const teams = getTeamContext()
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const stages = getStageContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/settings/team" class="underline">Team verwalten</a>.</p>
|
||||
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/team" class="underline">Team verwalten</a>.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each upNext as event}
|
||||
{@const eventRuns = runs.getByEvent(event.id).length}
|
||||
{@const eventStages = stages.getByEvent(event.id).length}
|
||||
<Card class="hover:border-primary transition-colors">
|
||||
<a href="/dashboard/events/{event.id}" class="block">
|
||||
<CardHeader>
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
{/if}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-sm text-muted-foreground">{eventRuns} Run{eventRuns === 1 ? '' : 's'}</p>
|
||||
<p class="text-sm text-muted-foreground">{eventStages} Stage{eventStages === 1 ? '' : 's'}</p>
|
||||
</CardContent>
|
||||
</a>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import {
|
||||
getEventSeriesContext,
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
import type { EventsResponse } from '$lib/types'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const stages = getStageContext()
|
||||
const participants = getEventParticipantContext()
|
||||
const series = getEventSeriesContext()
|
||||
|
||||
|
|
@ -244,7 +244,7 @@
|
|||
dayOf(s.until) || undefined,
|
||||
)}
|
||||
<Card>
|
||||
<CardContent class="py-4 space-y-2">
|
||||
<CardContent class="space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<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>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each group.items as event}
|
||||
{@const runCount = runs.getByEvent(event.id).length}
|
||||
{@const stageCount = stages.getByEvent(event.id).length}
|
||||
<Card class="hover:border-primary transition-colors">
|
||||
<a href="/dashboard/events/{event.id}" class="block">
|
||||
<CardHeader>
|
||||
|
|
@ -328,7 +328,7 @@
|
|||
{#if event.timing}
|
||||
<span class="flex items-center gap-1">
|
||||
<Timer class="size-3.5" />
|
||||
{runCount} Run{runCount === 1 ? '' : 's'}
|
||||
{stageCount} Stage{stageCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="flex items-center gap-1">
|
||||
|
|
@ -341,7 +341,7 @@
|
|||
</div>
|
||||
</CardContent>
|
||||
</a>
|
||||
<div class="px-6 pb-4 flex items-center justify-end gap-1 border-t pt-3">
|
||||
<div class="px-4 flex items-center justify-end gap-1 border-t pt-3">
|
||||
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} tooltip="Bearbeiten">
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -416,7 +416,7 @@
|
|||
mit Zeitnahme
|
||||
</label>
|
||||
<p class="text-xs text-muted-foreground -mt-2">
|
||||
Blendet Runs und Zeitnahme ein. Für Stammtisch oder Feierabendrunde
|
||||
Blendet Stages und Zeitnahme ein. Für Stammtisch oder Feierabendrunde
|
||||
unnötig.
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
import { page } from '$app/state'
|
||||
import { goto } from '$app/navigation'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import {
|
||||
|
|
@ -36,10 +36,10 @@
|
|||
Check,
|
||||
X,
|
||||
} from 'lucide-svelte'
|
||||
import type { EventParticipantsResponse, RunsResponse } from '$lib/types'
|
||||
import type { EventParticipantsResponse, StagesResponse } from '$lib/types'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const stages = getStageContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
const teams = getTeamContext()
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
|
||||
const eventRuns = $derived(event ? runs.getByEvent(event.id) : [])
|
||||
const eventStages = $derived(event ? stages.getByEvent(event.id) : [])
|
||||
|
||||
/**
|
||||
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
|
||||
|
|
@ -65,14 +65,14 @@
|
|||
!!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen',
|
||||
)
|
||||
|
||||
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Run bzw. Fahrer.
|
||||
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Stage bzw. Fahrer.
|
||||
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
|
||||
function riderPath(riderId: string) {
|
||||
return `/dashboard/events/${event?.id}/riders/${riderId}`
|
||||
}
|
||||
|
||||
function runPath(runId: string) {
|
||||
return `/dashboard/events/${event?.id}/runs/${runId}`
|
||||
function stagePath(stageId: string) {
|
||||
return `/dashboard/events/${event?.id}/stages/${stageId}`
|
||||
}
|
||||
|
||||
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
||||
|
|
@ -162,30 +162,30 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Run-Dialog
|
||||
let runDialog = $state(false)
|
||||
let runEdit = $state<RunsResponse | null>(null)
|
||||
let runForm = $state({ description: '' })
|
||||
// Stage-Dialog
|
||||
let stageDialog = $state(false)
|
||||
let stageEdit = $state<StagesResponse | null>(null)
|
||||
let stageForm = $state({ description: '' })
|
||||
|
||||
function openRunCreate() {
|
||||
runEdit = null
|
||||
runForm = { description: '' }
|
||||
runDialog = true
|
||||
function openStageCreate() {
|
||||
stageEdit = null
|
||||
stageForm = { description: '' }
|
||||
stageDialog = true
|
||||
}
|
||||
|
||||
function openRunEdit(r: RunsResponse) {
|
||||
runEdit = r
|
||||
runForm = { description: r.description ?? '' }
|
||||
runDialog = true
|
||||
function openStageEdit(r: StagesResponse) {
|
||||
stageEdit = r
|
||||
stageForm = { description: r.description ?? '' }
|
||||
stageDialog = true
|
||||
}
|
||||
|
||||
async function saveRun() {
|
||||
async function saveStage() {
|
||||
if (!event) return
|
||||
if (!runForm.description.trim()) return alert('Bezeichnung ist Pflicht')
|
||||
if (!stageForm.description.trim()) return alert('Bezeichnung ist Pflicht')
|
||||
try {
|
||||
if (runEdit) await runs.edit(runEdit.id, runForm)
|
||||
else await runs.create({ ...runForm, event: event.id })
|
||||
runDialog = false
|
||||
if (stageEdit) await stages.edit(stageEdit.id, stageForm)
|
||||
else await stages.create({ ...stageForm, event: event.id })
|
||||
stageDialog = false
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
|
|
@ -236,7 +236,7 @@
|
|||
|
||||
<Button
|
||||
onclick={() => (timeDialog = true)}
|
||||
disabled={eventParticipants.length === 0 || eventRuns.length === 0}
|
||||
disabled={eventParticipants.length === 0 || eventStages.length === 0}
|
||||
>
|
||||
<Timer class="h-4 w-4 mr-2" />
|
||||
Zeit erfassen
|
||||
|
|
@ -283,7 +283,7 @@
|
|||
{#if canManage && available.length === 0 && riders.scoped.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Das Team hat noch keine Fahrer.
|
||||
<a href="/dashboard/settings/riders" class="underline">Jetzt anlegen</a>
|
||||
<a href="/dashboard/team" class="underline">Jetzt anlegen</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
|
@ -322,8 +322,8 @@
|
|||
<Table.Cell class="font-medium">
|
||||
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||
<a href={riderPath(p.rider)} class="hover:underline">
|
||||
{#if rider && fullName(rider)}
|
||||
{fullName(rider)}
|
||||
{#if rider && riderName(rider)}
|
||||
{riderName(rider)}
|
||||
{:else}
|
||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||
{/if}
|
||||
|
|
@ -342,7 +342,7 @@
|
|||
</Table.Cell>
|
||||
{#if event.timing}
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{done} von {eventRuns.length} Runs
|
||||
{done} von {eventStages.length} Stages
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
||||
|
|
@ -411,34 +411,34 @@
|
|||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Runs — nur bei Events mit Zeitnahme -->
|
||||
<!-- Stages — nur bei Events mit Zeitnahme -->
|
||||
{#if event.timing}
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
<Timer class="h-5 w-5" />
|
||||
Runs
|
||||
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
|
||||
Stages
|
||||
<span class="text-muted-foreground font-normal text-base">({eventStages.length})</span>
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">Klick auf einen Run öffnet die Zeitnahme</p>
|
||||
<p class="text-sm text-muted-foreground">Klick auf eine Stage öffnet die Zeitnahme</p>
|
||||
</div>
|
||||
<Button onclick={openRunCreate}>
|
||||
<Button onclick={openStageCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Run anlegen
|
||||
Stage anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-0">
|
||||
{#if eventRuns.length === 0}
|
||||
{#if eventStages.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<Timer class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p class="font-medium">Noch keine Runs</p>
|
||||
<p class="font-medium">Noch keine Stages</p>
|
||||
<p class="text-sm text-muted-foreground mt-1 mb-4">Lege den ersten Lauf an.</p>
|
||||
<Button onclick={openRunCreate}>
|
||||
<Button onclick={openStageCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Ersten Run anlegen
|
||||
Erste Stage anlegen
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -451,13 +451,13 @@
|
|||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each eventRuns as run (run.id)}
|
||||
{@const runTimes = times.getByRun(run.id)}
|
||||
{@const active = runTimes.filter((t) => t.status === 'active').length}
|
||||
{@const ranked = runTimes.filter((t) => t.status === 'finished').length}
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(runPath(run.id))}>
|
||||
{#each eventStages as stage (stage.id)}
|
||||
{@const stageTimes = times.getByStage(stage.id)}
|
||||
{@const active = stageTimes.filter((t) => t.status === 'active').length}
|
||||
{@const ranked = stageTimes.filter((t) => t.status === 'finished').length}
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(stagePath(stage.id))}>
|
||||
<Table.Cell class="font-medium">
|
||||
<a href={runPath(run.id)} class="hover:underline">{run.description}</a>
|
||||
<a href={stagePath(stage.id)} class="hover:underline">{stage.description}</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if active > 0}
|
||||
|
|
@ -466,10 +466,10 @@
|
|||
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" onclick={() => openRunEdit(run)}>
|
||||
<Button variant="ghost" size="icon" onclick={() => openStageEdit(stage)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onclick={() => runs.remove(run)}>
|
||||
<Button variant="ghost" size="icon" onclick={() => stages.remove(stage)}>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
|
|
@ -507,12 +507,12 @@
|
|||
<Select.Root type="single" bind:value={form.rider}>
|
||||
<Select.Trigger class="w-full">
|
||||
{form.rider
|
||||
? fullName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
|
||||
? riderName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
|
||||
: 'Fahrer wählen…'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each available as r (r.id)}
|
||||
<Select.Item value={r.id}>{fullName(r) || '— ohne Name —'}</Select.Item>
|
||||
<Select.Item value={r.id}>{riderName(r) || '— ohne Name —'}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
|
@ -539,29 +539,29 @@
|
|||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Run-Dialog -->
|
||||
<Dialog.Root bind:open={runDialog}>
|
||||
<!-- Stage-Dialog -->
|
||||
<Dialog.Root bind:open={stageDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{runEdit ? 'Run bearbeiten' : 'Neuer Run'}</Dialog.Title>
|
||||
<Dialog.Title>{stageEdit ? 'Stage bearbeiten' : 'Neue Stage'}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Run für „{event?.name}"
|
||||
Stage für „{event?.name}"
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
saveRun()
|
||||
saveStage()
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="run-desc">Bezeichnung *</Label>
|
||||
<Input id="run-desc" bind:value={runForm.description} placeholder="Stage 1 – Downhill" required />
|
||||
<Label for="stage-desc">Bezeichnung *</Label>
|
||||
<Input id="stage-desc" bind:value={stageForm.description} placeholder="Stage 1 – Downhill" required />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="ghost" onclick={() => (runDialog = false)}>Abbrechen</Button>
|
||||
<Button type="submit">{runEdit ? 'Speichern' : 'Anlegen'}</Button>
|
||||
<Button type="button" variant="ghost" onclick={() => (stageDialog = false)}>Abbrechen</Button>
|
||||
<Button type="submit">{stageEdit ? 'Speichern' : 'Anlegen'}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Fahrerübersicht: alle Runs des Events aus Sicht eines Fahrers. Gezeigt
|
||||
* wird je Run seine Zeit, sein Platz und ob gerade gemessen wird — die
|
||||
* Zeitnahme selbst passiert auf der Run-Seite, dorthin führt jede Zeile.
|
||||
* Fahrerübersicht: alle Stages des Events aus Sicht eines Fahrers. Gezeigt
|
||||
* wird je Stage seine Zeit, sein Platz und ob gerade gemessen wird — die
|
||||
* Zeitnahme selbst passiert auf der Stage-Seite, dorthin führt jede Zeile.
|
||||
*/
|
||||
import { page } from '$app/state'
|
||||
import { goto } from '$app/navigation'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const stages = getStageContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
const participants = getEventParticipantContext()
|
||||
|
|
@ -37,40 +37,40 @@
|
|||
const valid = $derived(!!event && !!rider && !!entry)
|
||||
|
||||
/**
|
||||
* Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
|
||||
* Eine Zeile je Stage des Events — auch für Stages ohne Zeit, damit sichtbar
|
||||
* bleibt, was dem Fahrer noch fehlt.
|
||||
*/
|
||||
const rows = $derived.by(() => {
|
||||
if (!valid || !event || !rider) return []
|
||||
|
||||
// clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste
|
||||
// aktuell; bei einer Handvoll Runs ist das billig.
|
||||
// aktuell; bei einer Handvoll Stages ist das billig.
|
||||
const own = times.getByRider(rider.id, clock.now)
|
||||
|
||||
return runs.getByEvent(event.id).map((run) => {
|
||||
const forRun = own.filter((t) => t.run === run.id)
|
||||
const active = forRun.find((t) => t.status === 'active')
|
||||
return stages.getByEvent(event.id).map((stage) => {
|
||||
const forStage = own.filter((t) => t.stage === stage.id)
|
||||
const active = forStage.find((t) => t.status === 'active')
|
||||
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
|
||||
const best = forRun
|
||||
const best = forStage
|
||||
.filter((t) => t.status === 'finished')
|
||||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
|
||||
|
||||
return {
|
||||
run,
|
||||
stage,
|
||||
active,
|
||||
best,
|
||||
others: forRun.filter((t) => t.status !== 'active' && t.status !== 'finished'),
|
||||
placement: best ? times.placement(run.id, rider.id) : null,
|
||||
others: forStage.filter((t) => t.status !== 'active' && t.status !== 'finished'),
|
||||
placement: best ? times.placement(stage.id, rider.id) : null,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
|
||||
let timeDialog = $state(false)
|
||||
let dialogRun = $state<string | null>(null)
|
||||
let dialogStage = $state<string | null>(null)
|
||||
|
||||
function openTimeDialog(runId: string | null = null) {
|
||||
dialogRun = runId
|
||||
function openTimeDialog(stageId: string | null = null) {
|
||||
dialogStage = stageId
|
||||
timeDialog = true
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{rider ? fullName(rider) : 'Fahrer'} – {event?.name ?? 'Event'}</title></svelte:head>
|
||||
<svelte:head><title>{rider ? riderName(rider) : 'Fahrer'} – {event?.name ?? 'Event'}</title></svelte:head>
|
||||
|
||||
{#if !valid}
|
||||
<Card>
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
<Badge variant="outline" class="text-lg px-3 py-1">{entry.number}</Badge>
|
||||
{/if}
|
||||
<h1 class="text-3xl font-bold tracking-tight">
|
||||
{fullName(rider) || 'Ohne Name'}
|
||||
{riderName(rider) || 'Ohne Name'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -131,7 +131,7 @@
|
|||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Runs</CardTitle>
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Stages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
|
||||
|
|
@ -163,19 +163,19 @@
|
|||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Timer class="h-4 w-4 text-primary" />
|
||||
Runs ({rows.length})
|
||||
Stages ({rows.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
{#if rows.length === 0}
|
||||
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
|
||||
Für dieses Event gibt es noch keine Runs.
|
||||
Für dieses Event gibt es noch keine Stages.
|
||||
</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Run</Table.Head>
|
||||
<Table.Head>Stage</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-20">Platz</Table.Head>
|
||||
|
|
@ -183,17 +183,17 @@
|
|||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as row (row.run.id)}
|
||||
{#each rows as row (row.stage.id)}
|
||||
<Table.Row
|
||||
class="cursor-pointer"
|
||||
onclick={() => goto(`/dashboard/events/${event.id}/runs/${row.run.id}`)}
|
||||
onclick={() => goto(`/dashboard/events/${event.id}/stages/${row.stage.id}`)}
|
||||
>
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href="/dashboard/events/{event.id}/runs/{row.run.id}"
|
||||
href="/dashboard/events/{event.id}/stages/{row.stage.id}"
|
||||
class="hover:underline"
|
||||
>
|
||||
{row.run.description || 'Run'}
|
||||
{row.stage.description || 'Stage'}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
|
|
@ -244,5 +244,5 @@
|
|||
bind:open={timeDialog}
|
||||
eventId={event?.id ?? ''}
|
||||
riderId={rider?.id ?? null}
|
||||
runId={dialogRun}
|
||||
stageId={dialogStage}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Run-Übersicht: laufende und gewertete Zeiten in einer Tabelle, die
|
||||
* Stage-Übersicht: laufende und gewertete Zeiten in einer Tabelle, die
|
||||
* 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
|
||||
* Strecke ist ein versehentlicher Treffer sonst schnell passiert, und
|
||||
|
|
@ -8,8 +8,8 @@
|
|||
*/
|
||||
import { page } from '$app/state'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const stages = getStageContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
const participants = getEventParticipantContext()
|
||||
|
|
@ -30,17 +30,17 @@
|
|||
const clock = ticker()
|
||||
|
||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||
const run = $derived(page.params.runId ? runs.getById(page.params.runId) : undefined)
|
||||
const stage = $derived(page.params.stageId ? stages.getById(page.params.stageId) : undefined)
|
||||
|
||||
// Ein Run gehört genau zu einem Event; passt die URL nicht zusammen, ist
|
||||
// Ein Stage gehört genau zu einem Event; passt die URL nicht zusammen, ist
|
||||
// sie von Hand zusammengebaut oder veraltet.
|
||||
const valid = $derived(!!event && !!run && run.event === event.id)
|
||||
const valid = $derived(!!event && !!stage && stage.event === event.id)
|
||||
|
||||
// Am Start steht, wer beim Event zugesagt hat.
|
||||
const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
|
||||
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
|
||||
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
|
||||
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
|
||||
const running = $derived(valid && stage ? times.runningByStage(stage.id, clock.now) : [])
|
||||
const leaderboard = $derived(valid && stage ? times.getLeaderboard(stage.id) : [])
|
||||
const unranked = $derived(valid && stage ? times.unrankedByStage(stage.id) : [])
|
||||
|
||||
/**
|
||||
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
}
|
||||
|
||||
// --- Zeitnahme --------------------------------------------------------
|
||||
// Start und Stopp laufen ausschließlich über TimeDialog; der Run ist
|
||||
// Start und Stopp laufen ausschließlich über TimeDialog; die Stage ist
|
||||
// dabei vorbelegt, den Fahrer wählt man dort.
|
||||
let timeDialog = $state(false)
|
||||
|
||||
|
|
@ -75,14 +75,14 @@
|
|||
rows.push([
|
||||
String(i + 1),
|
||||
event ? participants.numberOf(event.id, t.rider) : '',
|
||||
r ? fullName(r) : '',
|
||||
r ? riderName(r) : '',
|
||||
t.formattedTime ?? '',
|
||||
String(t.correction ?? 0),
|
||||
])
|
||||
})
|
||||
|
||||
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
|
||||
const safeName = (run?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
|
||||
const safeName = (stage?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
|
|
@ -93,13 +93,13 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{run?.description ?? 'Run'} – {event?.name ?? 'Event'}</title></svelte:head>
|
||||
<svelte:head><title>{stage?.description ?? 'Stage'} – {event?.name ?? 'Event'}</title></svelte:head>
|
||||
|
||||
{#if !valid}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">Run nicht gefunden</p>
|
||||
<p class="text-lg font-medium">Stage nicht gefunden</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
|
||||
</p>
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if event && run}
|
||||
{:else if event && stage}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
<ArrowLeft class="h-3 w-3" />
|
||||
{event.name || 'Event'}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
|
||||
<h1 class="text-3xl font-bold tracking-tight">{stage.description || 'Stage'}</h1>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start
|
||||
</p>
|
||||
|
|
@ -195,7 +195,7 @@
|
|||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{#if r}
|
||||
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
|
||||
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
|
|
@ -233,7 +233,7 @@
|
|||
</Badge>
|
||||
<span class="truncate">
|
||||
{#if r}
|
||||
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
|
||||
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
|
|
@ -249,4 +249,4 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} runId={run?.id ?? null} />
|
||||
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} stageId={stage?.id ?? null} />
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -2,27 +2,19 @@
|
|||
/**
|
||||
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
|
||||
*
|
||||
* „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die
|
||||
* Menschen: Jedes Mitglied ist ein Fahrer, ein Konto hat aber nicht jeder.
|
||||
* Beides an einer Stelle, weil es sonst zwei Wahrheiten über dieselbe
|
||||
* Person gäbe. Der Reiter erscheint nur für die Teamleitung.
|
||||
* Hier steht nur noch, was einen selbst betrifft — das eigene Profil und
|
||||
* die Flag-Typen. Team und Kader sind unter „Team" zu Hause; sie sind
|
||||
* keine Einstellung, sondern der Gegenstand der Anwendung.
|
||||
*/
|
||||
import { page } from '$app/state'
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { Flag, User, Users, Bike } from 'lucide-svelte'
|
||||
import { Flag, User } from 'lucide-svelte'
|
||||
|
||||
let { children } = $props()
|
||||
|
||||
const teams = getTeamContext()
|
||||
|
||||
const tabs = $derived([
|
||||
const tabs = [
|
||||
{ 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 },
|
||||
])
|
||||
]
|
||||
|
||||
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
|
||||
// auf jedem Unterpfad mit aktiv.
|
||||
|
|
|
|||
|
|
@ -5,16 +5,40 @@
|
|||
* also niemand für jemand anderen ändern.
|
||||
*/
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { KeyRound, User } from 'lucide-svelte'
|
||||
import { Camera, KeyRound, Trash2, User } from 'lucide-svelte'
|
||||
|
||||
let name = $state(auth.user?.name ?? '')
|
||||
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 profileMessage = $state<string | null>(null)
|
||||
let profileError = $state<string | null>(null)
|
||||
|
|
@ -50,6 +74,56 @@
|
|||
}
|
||||
}
|
||||
|
||||
// --- 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 newPassword = $state('')
|
||||
let confirmPassword = $state('')
|
||||
|
|
@ -109,36 +183,53 @@
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar user={auth.user} size={64} />
|
||||
<div class="space-y-1">
|
||||
<Label for="avatar">Bild</Label>
|
||||
<!--
|
||||
Das Bild steht neben Name und Adresse und reicht ueber beide
|
||||
Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt
|
||||
unsichtbar darunter, angeklickt wird das Bild selbst — das
|
||||
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
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="block w-full text-sm"
|
||||
class="sr-only"
|
||||
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Ohne Bild stehen deine Initialen da.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<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 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">
|
||||
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
|
||||
Team-Administration über PocketBase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if profileError}
|
||||
<p class="text-sm text-destructive">{profileError}</p>
|
||||
|
|
@ -187,4 +278,43 @@
|
|||
</Button>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,587 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
<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>
|
||||
953
frontend/src/routes/dashboard/team/+page.svelte
Normal file
953
frontend/src/routes/dashboard/team/+page.svelte
Normal file
|
|
@ -0,0 +1,953 @@
|
|||
<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>
|
||||
|
|
@ -148,8 +148,15 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<!-- Dieselben Icons wie im Kopf der Trail-Seite. -->
|
||||
<!--
|
||||
Eine Zeile, 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">
|
||||
<Ruler class="size-4" />
|
||||
{formatKm(version?.distance_m)}
|
||||
|
|
@ -158,27 +165,9 @@
|
|||
<TrendingUp class="size-4" />
|
||||
{version?.ascent_m ? `${version.ascent_m} hm` : '–'}
|
||||
</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
|
||||
class="flex items-center gap-1.5"
|
||||
class:text-muted-foreground={!freshMarkers}
|
||||
class:text-foreground={freshMarkers}
|
||||
class:font-medium={freshMarkers}
|
||||
use:tooltip={{
|
||||
content: freshMarkers
|
||||
|
|
@ -194,7 +183,7 @@
|
|||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1.5"
|
||||
class:text-muted-foreground={!freshComments}
|
||||
class:text-foreground={freshComments}
|
||||
class:font-medium={freshComments}
|
||||
use:tooltip={{
|
||||
content: freshComments
|
||||
|
|
@ -208,6 +197,17 @@
|
|||
<span class="size-1.5 rounded-full bg-primary"></span>
|
||||
{/if}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import IconPicker from '$lib/components/IconPicker.svelte'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import { tooltip } from '$lib/stores/app.svelte'
|
||||
import { formatDateTime, formatRelative } from '$lib/time'
|
||||
import { positionAtDistance } from '$lib/gpx'
|
||||
import { app } from '$lib/stores/app.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -276,9 +277,9 @@
|
|||
}
|
||||
|
||||
// --- Hilfsfunktionen -------------------------------------------------
|
||||
function formatDate(s: string) {
|
||||
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
// Dieselbe Formatierung stand hier als eigene Zeile Code und in
|
||||
// $lib/time; jetzt kommt sie nur noch von dort.
|
||||
const formatDate = formatDateTime
|
||||
|
||||
async function setStatus(status: string) {
|
||||
if (!trail || status === trail.status) return
|
||||
|
|
@ -368,9 +369,17 @@
|
|||
</div>
|
||||
|
||||
{#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">
|
||||
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
|
||||
gemeldet am {formatDate(trail.status_changed)}
|
||||
gemeldet
|
||||
<span use:tooltip={{ content: formatDateTime(trail.status_changed) }}>
|
||||
{formatRelative(trail.status_changed)}
|
||||
</span>
|
||||
{#if trail.status_by}
|
||||
von {teams.userLabel(trail.status_by)}
|
||||
{/if}
|
||||
|
|
|
|||
228
frontend/src/routes/invite/[token]/+page.svelte
Normal file
228
frontend/src/routes/invite/[token]/+page.svelte
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<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>
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { LoaderCircle } from 'lucide-svelte'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import Logo from '$lib/components/Logo.svelte'
|
||||
|
||||
let email = $state('')
|
||||
|
|
@ -82,7 +82,7 @@
|
|||
|
||||
<Button type="submit" class="w-full" disabled={submitting}>
|
||||
{#if submitting}
|
||||
<LoaderCircle class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Spinner class="mr-2 size-4" />
|
||||
Wird angemeldet…
|
||||
{:else}
|
||||
Anmelden
|
||||
|
|
|
|||
95
frontend/static/pedaler.svg
Normal file
95
frontend/static/pedaler.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 55 KiB |
Loading…
Reference in a new issue