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
|
## 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/`.
|
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
|
Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe
|
||||||
`backend/README.md`.
|
`backend/README.md`.
|
||||||
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
|
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
|
||||||
- **Collections**: users, teams, 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
|
- **Authentication**: Handled through `src/lib/stores/pocketbase.svelte.ts` with the `AuthStore` class
|
||||||
- **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs
|
- **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs
|
||||||
|
|
||||||
|
|
@ -136,7 +149,18 @@ tippy.js, which the action initializes, updates and destroys.
|
||||||
|
|
||||||
### File Structure Notes
|
### 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/`
|
- Reusable components in `src/lib/components/`
|
||||||
- Stores use `.svelte.ts` extension for Svelte 5 runes
|
- Stores use `.svelte.ts` extension for Svelte 5 runes
|
||||||
- Static assets in `static/`
|
- Static assets in `static/`
|
||||||
|
|
|
||||||
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
|
Das Schema liegt als versionierte Migration in `pb_migrations/` und wird beim
|
||||||
Start automatisch angewendet. Es enthält die sechs fachlichen Collections
|
Start automatisch angewendet. Es enthält die sechs fachlichen Collections
|
||||||
`users`, `teams`, `events`, `runs`, `riders`, `times`.
|
`users`, `teams`, `events`, `stages`, `riders`, `times`.
|
||||||
|
|
||||||
`1754400000_init_schema.js` ist ein Snapshot der produktiven Instanz. Er wurde
|
`1754400000_init_schema.js` ist ein Snapshot der produktiven Instanz. Er wurde
|
||||||
per `GET /api/collections` abgezogen und ruft `importCollections` mit
|
per `GET /api/collections` abgezogen und ruft `importCollections` mit
|
||||||
|
|
@ -50,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
|
Ein frisch gestarteter Container hat damit dasselbe Schema wie die produktive
|
||||||
Instanz — aber **keine** Daten.
|
Instanz — aber **keine** Daten.
|
||||||
|
|
||||||
|
### Regeln: der Login-Vorspann ist Pflicht
|
||||||
|
|
||||||
|
Jede Regel, die `@request.auth.id` erwähnt, beginnt mit
|
||||||
|
`@request.auth.id != "" && (…)`. Das ist kein Stilmittel, sondern schließt ein
|
||||||
|
Loch:
|
||||||
|
|
||||||
|
**Eine leere Relation ist in PocketBase gleich dem leeren `@request.auth.id`
|
||||||
|
einer anonymen Anfrage.** `team.admins.id ?= @request.auth.id` ist damit für
|
||||||
|
jedes Team **ohne Admins** wahr — und ohne Admins ist jedes frisch angelegte
|
||||||
|
Team. Dasselbe gilt für jede optionale Relation: `created_by`, `stewards`,
|
||||||
|
`rider.user`.
|
||||||
|
|
||||||
|
Nachgewiesen an einer lokalen Instanz mit dem Schema aus diesem Repo: Ein
|
||||||
|
`POST` auf `/api/collections/riders/records` **ohne jeden Token** legte den
|
||||||
|
Fahrer an. Betroffen waren nach demselben Muster `teams` (update), `riders`,
|
||||||
|
`trails`, `trail_versions`, `trail_flags`, `trail_markers`, `trail_comments`,
|
||||||
|
`event_series` und `event_participants`.
|
||||||
|
|
||||||
|
`1754501700_rules_require_login.js` setzt den Vorspann vor jede betroffene
|
||||||
|
Regel. Er kann nichts erlauben, was vorher verboten war — er nimmt nur die
|
||||||
|
anonyme Anfrage heraus, die keine dieser Regeln je erfüllen sollte. Gelesen
|
||||||
|
werden die Regeln aus der Datenbank, eine Handänderung aus dem Admin-UI wird
|
||||||
|
also abgesichert und nicht überschrieben.
|
||||||
|
|
||||||
|
Nach dem Deploy einmal gegenprüfen — das muss scheitern:
|
||||||
|
|
||||||
|
curl -X POST https://api.stammtisch-hersbruck.de/api/collections/riders/records \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"team":"<TEAM_ID>","name":"Test"}'
|
||||||
|
|
||||||
|
Wer eine neue Regel schreibt, setzt den Vorspann mit. Sonst reißt dasselbe
|
||||||
|
Loch an der nächsten Stelle wieder auf.
|
||||||
|
|
||||||
|
### `1754501300` scheitert auf einer frischen Datenbank
|
||||||
|
|
||||||
|
Bekannt und **nicht behoben**: Bei `docker compose up --build` mit leerem
|
||||||
|
`pb_data` legt der Snapshot `1754400000` die Collection `riders` bereits ohne
|
||||||
|
das Feld `event` an. `1754501300_event_participants.js` sucht danach mit
|
||||||
|
`findRecordsByFilter('riders', 'event != "")` und bricht mit
|
||||||
|
`unknown field "event"` ab — PocketBase startet dann gar nicht.
|
||||||
|
|
||||||
|
Auf der produktiven Instanz tritt das nicht auf, dort gibt es das Feld noch.
|
||||||
|
Angefasst wurde die Migration deshalb nicht; wer lokal von Null startet,
|
||||||
|
braucht diese eine Zeile:
|
||||||
|
|
||||||
|
const existing = riders.fields.getByName('event')
|
||||||
|
? app.findRecordsByFilter('riders', 'event != ""', '', 0, 0)
|
||||||
|
: []
|
||||||
|
|
||||||
|
## Hooks (`pb_hooks/`)
|
||||||
|
|
||||||
|
Serverseitiges JavaScript, das PocketBase beim Start lädt (`--hooksDir`). Wie
|
||||||
|
`pb_migrations/` kommt das Verzeichnis per `COPY` ins Image — eine Änderung
|
||||||
|
wirkt also erst nach `docker compose up -d --build`.
|
||||||
|
|
||||||
|
Bisher liegt dort ein Anliegen: die öffentlichen Routen der Einladungslinks.
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `team_invites.pb.js` | `GET /api/team-invites/{token}`, `POST …/accept`, `POST …/join` |
|
||||||
|
| `team_invites_lib.js` | Gemeinsame Helfer der drei Routen |
|
||||||
|
|
||||||
|
Warum überhaupt ein Hook? Wer über einen Link beitritt, ist noch nicht
|
||||||
|
angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel
|
||||||
|
einzeln erlauben darf: ein Konto anlegen, sich in `teams.users` eintragen und
|
||||||
|
einen Fahrer anlegen. Ein Hook arbeitet an den Regeln vorbei und kann genau
|
||||||
|
diese Kombination erlauben — gegen einen gültigen Token und sonst nicht.
|
||||||
|
|
||||||
|
**Zwei Fallstricke, an denen die erste Fassung scheiterte:**
|
||||||
|
|
||||||
|
1. Jeder Route-Handler läuft in einer **eigenen** JS-Laufzeit. Funktionen, die
|
||||||
|
in derselben Datei neben `routerAdd` stehen, sind im Handler *nicht*
|
||||||
|
sichtbar (`ReferenceError`). Gemeinsamer Code gehört deshalb in ein Modul,
|
||||||
|
das der Handler sich per ``require(`${__hooks}/…`)`` selbst holt.
|
||||||
|
2. Was `record.get()` bei einer Mehrfach-Relation liefert, ist ein Go-Slice
|
||||||
|
und kein JS-Array — `indexOf` und Konsorten sind darauf nicht verlässlich.
|
||||||
|
Anhängen geht über den Modifier `record.set('users+', id)`, Prüfen über
|
||||||
|
einen Filter.
|
||||||
|
|
||||||
## Datenpersistenz
|
## Datenpersistenz
|
||||||
|
|
||||||
Sämtliche Laufzeitdaten liegen in der SQLite-DB unter `/pb/pb_data`: Datensätze,
|
Sämtliche Laufzeitdaten liegen in der SQLite-DB unter `/pb/pb_data`: Datensätze,
|
||||||
|
|
|
||||||
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",
|
"createRule": "team.users.id ?= @request.auth.id",
|
||||||
"updateRule": "team.users.id ?= @request.auth.id",
|
"updateRule": "team.users.id ?= @request.auth.id",
|
||||||
"deleteRule": "team.users.id ?= @request.auth.id",
|
"deleteRule": "team.users.id ?= @request.auth.id",
|
||||||
"name": "runs",
|
"name": "stages",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
|
|
@ -709,7 +709,7 @@ migrate((app) => {
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"pattern": "",
|
"pattern": "",
|
||||||
"presentable": false,
|
"presentable": true,
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"required": false,
|
"required": false,
|
||||||
"system": false,
|
"system": false,
|
||||||
|
|
@ -764,36 +764,6 @@ migrate((app) => {
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "autodate"
|
"type": "autodate"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"help": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text2208304744",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "firstname",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": true,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"help": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text824489398",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "lastname",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": true,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"cascadeDelete": false,
|
"cascadeDelete": false,
|
||||||
"collectionId": "pbc_1568971955",
|
"collectionId": "pbc_1568971955",
|
||||||
|
|
@ -946,7 +916,7 @@ migrate((app) => {
|
||||||
"id": "ltfephwe",
|
"id": "ltfephwe",
|
||||||
"maxSelect": 1,
|
"maxSelect": 1,
|
||||||
"minSelect": 0,
|
"minSelect": 0,
|
||||||
"name": "run",
|
"name": "stage",
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"required": false,
|
"required": false,
|
||||||
"system": false,
|
"system": false,
|
||||||
|
|
@ -2105,6 +2075,138 @@ migrate((app) => {
|
||||||
],
|
],
|
||||||
"indexes": [],
|
"indexes": [],
|
||||||
"system": false
|
"system": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_team_invites",
|
||||||
|
"listRule": "@request.auth.id != \"\" && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id)",
|
||||||
|
"viewRule": "@request.auth.id != \"\" && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id)",
|
||||||
|
"createRule": "@request.auth.id != \"\" && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id) && @request.body.token:isset = false",
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": "@request.auth.id != \"\" && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id)",
|
||||||
|
"name": "team_invites",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_1568971955",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation_invite_team",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "team",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-zA-Z0-9]{32}",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_invite_token",
|
||||||
|
"max": 32,
|
||||||
|
"min": 32,
|
||||||
|
"name": "token",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation_invite_created_by",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "created_by",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date_invite_expires",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "expires",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number_invite_max_uses",
|
||||||
|
"max": null,
|
||||||
|
"min": 0,
|
||||||
|
"name": "max_uses",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number_invite_uses",
|
||||||
|
"max": null,
|
||||||
|
"min": 0,
|
||||||
|
"name": "uses",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_created",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_updated",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_team_invites_token` ON `team_invites` (`token`)"
|
||||||
|
],
|
||||||
|
"system": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
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 {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tailwind 4 nimmt Knöpfen den Zeigefinger und überlässt sie dem
|
||||||
|
* Browser-Standard — was am Ende ein Pfeil ist, der nichts verspricht.
|
||||||
|
* Hier steht er wieder, ein für alle Mal und nicht als Klasse an jedem
|
||||||
|
* einzelnen Element: Was sich anklicken lässt, sieht auch so aus.
|
||||||
|
*
|
||||||
|
* `label[for]` ist mitgemeint, weil versteckte Eingabefelder über ihr
|
||||||
|
* Label bedient werden — etwa das Profilbild in den Einstellungen.
|
||||||
|
*/
|
||||||
|
button,
|
||||||
|
summary,
|
||||||
|
label[for],
|
||||||
|
[role='button'],
|
||||||
|
[role='menuitem'],
|
||||||
|
[role='option'],
|
||||||
|
[role='tab'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Abgeschaltet heißt abgeschaltet — ein Zeigefinger verspräche dort zu viel. */
|
||||||
|
button:disabled,
|
||||||
|
[aria-disabled='true'],
|
||||||
|
[data-disabled] {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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">
|
<div class="relative {className}" id="logo">
|
||||||
{#if url}
|
{#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}
|
{:else}
|
||||||
<img alt="" class="absolute inset-0 h-full w-full object-contain" src="/schildl.svg">
|
<img alt="" class="absolute 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 scale-110 object-contain" src="/radl.svg">
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,17 @@
|
||||||
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
|
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
|
||||||
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
|
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
|
||||||
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
|
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
|
||||||
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die 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
|
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
|
||||||
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
|
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
|
||||||
*
|
*
|
||||||
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
|
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
|
||||||
* Zeit-Dialog mit Fahrer und Run vorbelegt.
|
* Zeit-Dialog mit Fahrer und Stage vorbelegt.
|
||||||
*/
|
*/
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
import TimeDialog from './TimeDialog.svelte'
|
import TimeDialog from './TimeDialog.svelte'
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
|
|
@ -35,15 +35,15 @@
|
||||||
let timeDialog = $state(false)
|
let timeDialog = $state(false)
|
||||||
let dialogEvent = $state('')
|
let dialogEvent = $state('')
|
||||||
let dialogRider = $state<string | null>(null)
|
let dialogRider = $state<string | null>(null)
|
||||||
let dialogRun = $state<string | null>(null)
|
let dialogStage = $state<string | null>(null)
|
||||||
|
|
||||||
function openTimeDialog(riderId: string | undefined, runId: string | undefined) {
|
function openTimeDialog(riderId: string | undefined, stageId: string | undefined) {
|
||||||
const run = runId ? runs.getById(runId) : undefined
|
const stage = stageId ? stages.getById(stageId) : undefined
|
||||||
if (!run?.event) return
|
if (!stage?.event) return
|
||||||
|
|
||||||
dialogEvent = run.event
|
dialogEvent = stage.event
|
||||||
dialogRider = riderId ?? null
|
dialogRider = riderId ?? null
|
||||||
dialogRun = run.id
|
dialogStage = stage.id
|
||||||
timeDialog = true
|
timeDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,25 +51,25 @@
|
||||||
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
|
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
|
||||||
const open = $derived(single || expanded)
|
const open = $derived(single || expanded)
|
||||||
|
|
||||||
/** Die Startnummer gilt je Event, deshalb der Umweg über den Run. */
|
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */
|
||||||
function riderLabel(riderId: string | undefined, runId: string | undefined) {
|
function riderLabel(riderId: string | undefined, stageId: string | undefined) {
|
||||||
const eventId = runId ? runs.getById(runId)?.event : undefined
|
const eventId = stageId ? stages.getById(stageId)?.event : undefined
|
||||||
if (!eventId) {
|
if (!eventId) {
|
||||||
const r = riderId ? riders.getById(riderId) : undefined
|
const r = riderId ? riders.getById(riderId) : undefined
|
||||||
return r ? fullName(r) : 'Unbekannter Fahrer'
|
return r ? riderName(r) : 'Unbekannter Fahrer'
|
||||||
}
|
}
|
||||||
return participants.riderLabel(eventId, riderId)
|
return participants.riderLabel(eventId, riderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPath(runId: string | undefined) {
|
function stagePath(stageId: string | undefined) {
|
||||||
const run = runId ? runs.getById(runId) : undefined
|
const stage = stageId ? stages.getById(stageId) : undefined
|
||||||
if (!run?.event) return null
|
if (!stage?.event) return null
|
||||||
return `/dashboard/events/${run.event}/runs/${run.id}`
|
return `/dashboard/events/${stage.event}/stages/${stage.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function runLabel(runId: string | undefined) {
|
function stageLabel(stageId: string | undefined) {
|
||||||
const run = runId ? runs.getById(runId) : undefined
|
const stage = stageId ? stages.getById(stageId) : undefined
|
||||||
return run?.description || 'Run'
|
return stage?.description || 'Stage'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -113,15 +113,15 @@
|
||||||
{#if open}
|
{#if open}
|
||||||
<ul class="divide-y max-h-[50vh] overflow-y-auto">
|
<ul class="divide-y max-h-[50vh] overflow-y-auto">
|
||||||
{#each running as t (t.id)}
|
{#each running as t (t.id)}
|
||||||
{@const path = runPath(t.run)}
|
{@const path = stagePath(t.stage)}
|
||||||
<li class="flex items-center gap-2 px-3 py-2">
|
<li class="flex items-center gap-2 px-3 py-2">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium truncate">{riderLabel(t.rider, t.run)}</p>
|
<p class="text-sm font-medium truncate">{riderLabel(t.rider, t.stage)}</p>
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
{#if path}
|
{#if path}
|
||||||
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
|
<a href={path} class="hover:underline">{stageLabel(t.stage)}</a>
|
||||||
{:else}
|
{:else}
|
||||||
{runLabel(t.run)}
|
{stageLabel(t.stage)}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -135,7 +135,7 @@
|
||||||
size="icon"
|
size="icon"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
tooltip="Zeit erfassen"
|
tooltip="Zeit erfassen"
|
||||||
onclick={() => openTimeDialog(t.rider, t.run)}
|
onclick={() => openTimeDialog(t.rider, t.stage)}
|
||||||
>
|
>
|
||||||
<Timer class="size-4" />
|
<Timer class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -150,6 +150,6 @@
|
||||||
bind:open={timeDialog}
|
bind:open={timeDialog}
|
||||||
eventId={dialogEvent}
|
eventId={dialogEvent}
|
||||||
riderId={dialogRider}
|
riderId={dialogRider}
|
||||||
runId={dialogRun}
|
stageId={dialogStage}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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">
|
<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
|
* darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit
|
||||||
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
|
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
|
||||||
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
|
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
|
||||||
* Tabellen noch die Toast-Leiste lösen sie direkt aus.
|
* Tabellen noch die Toast-Leiste lösen sie direkt aus.
|
||||||
*/
|
*/
|
||||||
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
|
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -21,24 +21,24 @@
|
||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
eventId,
|
eventId,
|
||||||
riderId = null,
|
riderId = null,
|
||||||
runId = null,
|
stageId = null,
|
||||||
}: {
|
}: {
|
||||||
open?: boolean
|
open?: boolean
|
||||||
/** Event, aus dem Fahrer und Runs zur Auswahl stehen. */
|
/** Event, aus dem Fahrer und Stages zur Auswahl stehen. */
|
||||||
eventId: string
|
eventId: string
|
||||||
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
|
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
|
||||||
riderId?: string | null
|
riderId?: string | null
|
||||||
runId?: string | null
|
stageId?: string | null
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
let selectedRider = $state('')
|
let selectedRider = $state('')
|
||||||
let selectedRun = $state('')
|
let selectedStage = $state('')
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
let confirmDnf = $state(false)
|
let confirmDnf = $state(false)
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
selectedRider = riderId ?? ''
|
selectedRider = riderId ?? ''
|
||||||
selectedRun = runId ?? ''
|
selectedStage = stageId ?? ''
|
||||||
error = null
|
error = null
|
||||||
confirmDnf = false
|
confirmDnf = false
|
||||||
})
|
})
|
||||||
|
|
@ -63,16 +63,16 @@
|
||||||
.filter((r) => !!r)
|
.filter((r) => !!r)
|
||||||
: [],
|
: [],
|
||||||
)
|
)
|
||||||
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
|
const eventStages = $derived(eventId ? stages.getByEvent(eventId) : [])
|
||||||
|
|
||||||
const riderLabel = $derived(
|
const riderLabel = $derived(
|
||||||
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
|
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
|
||||||
)
|
)
|
||||||
const runLabel = $derived(
|
const stageLabel = $derived(
|
||||||
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
|
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
|
// Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und
|
||||||
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
|
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
|
||||||
|
|
@ -80,14 +80,14 @@
|
||||||
ready
|
ready
|
||||||
? times
|
? times
|
||||||
.allRunning(clock.now)
|
.allRunning(clock.now)
|
||||||
.find((t) => t.run === selectedRun && t.rider === selectedRider)
|
.find((t) => t.stage === selectedStage && t.rider === selectedRider)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
|
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
|
||||||
const existing = $derived(
|
const existing = $derived(
|
||||||
ready && !active
|
ready && !active
|
||||||
? times.getByRun(selectedRun).find((t) => t.rider === selectedRider && t.stop)
|
? times.getByStage(selectedStage).find((t) => t.rider === selectedRider && t.stop)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
busy = true
|
busy = true
|
||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
await times.start(selectedRun, selectedRider)
|
await times.start(selectedStage, selectedRider)
|
||||||
open = false
|
open = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
|
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
|
||||||
|
|
@ -128,7 +128,7 @@
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Zeit erfassen</Dialog.Title>
|
<Dialog.Title>Zeit erfassen</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Fahrer und 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.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
|
|
@ -151,17 +151,17 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Run</Label>
|
<Label>Stage</Label>
|
||||||
<Select.Root type="single" bind:value={selectedRun} disabled={eventRuns.length === 0}>
|
<Select.Root type="single" bind:value={selectedStage} disabled={eventStages.length === 0}>
|
||||||
<Select.Trigger class="w-full">{runLabel}</Select.Trigger>
|
<Select.Trigger class="w-full">{stageLabel}</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each eventRuns as r (r.id)}
|
{#each eventStages as r (r.id)}
|
||||||
<Select.Item value={r.id}>{r.description || 'Run'}</Select.Item>
|
<Select.Item value={r.id}>{r.description || 'Stage'}</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
{#if eventRuns.length === 0}
|
{#if eventStages.length === 0}
|
||||||
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Runs.</p>
|
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Stages.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@
|
||||||
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx'
|
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx'
|
||||||
import FlagIcon from './FlagIcon.svelte'
|
import FlagIcon from './FlagIcon.svelte'
|
||||||
import { trailStatusColor } from '$lib/trailStatus'
|
import { trailStatusColor } from '$lib/trailStatus'
|
||||||
import { env } from '$env/dynamic/public'
|
|
||||||
import { mode } from 'mode-watcher'
|
import { mode } from 'mode-watcher'
|
||||||
|
|
||||||
maplibregl.setWorkerUrl(workerUrl)
|
maplibregl.setWorkerUrl(workerUrl)
|
||||||
|
|
@ -84,29 +83,24 @@
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen
|
* Basiskarten von OpenFreeMap: zurückhaltend gezeichnet — die Trail-Linie
|
||||||
* keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt
|
* liegt dadurch deutlich sichtbarer darüber — und in einer echten dunklen
|
||||||
* dadurch deutlich sichtbarer darüber — und es gibt sie in einer echten
|
* Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein dunkleres
|
||||||
* dunklen Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein
|
* Bild, keine dunkle Karte.
|
||||||
* 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
|
const STYLE_LIGHT = 'https://tiles.openfreemap.org/styles/positron'
|
||||||
// PUBLIC_-Variablen. Über $env/dynamic/public statt static, weil die
|
const STYLE_DARK = 'https://tiles.openfreemap.org/styles/dark'
|
||||||
// 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 isDark = $derived(mode.current === 'dark')
|
const isDark = $derived(mode.current === 'dark')
|
||||||
|
|
||||||
|
|
@ -143,18 +137,7 @@
|
||||||
function build() {
|
function build() {
|
||||||
const instance = new maplibregl.Map({
|
const instance = new maplibregl.Map({
|
||||||
container,
|
container,
|
||||||
style: {
|
style: isDark ? STYLE_DARK : STYLE_LIGHT,
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
osm: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: isDark ? TILES_DARK : TILES_LIGHT,
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: ATTRIBUTION,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
|
||||||
},
|
|
||||||
center: FALLBACK_CENTER,
|
center: FALLBACK_CENTER,
|
||||||
zoom: 11,
|
zoom: 11,
|
||||||
attributionControl: controls ? undefined : false,
|
attributionControl: controls ? undefined : false,
|
||||||
|
|
@ -166,8 +149,13 @@
|
||||||
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
|
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
|
ready = true
|
||||||
|
drawTrail(instance)
|
||||||
})
|
})
|
||||||
|
|
||||||
cleanupMap = () => {
|
cleanupMap = () => {
|
||||||
|
|
@ -252,18 +240,26 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trail-Linie zeichnen bzw. aktualisieren
|
/**
|
||||||
$effect(() => {
|
* Die layerbezogenen Listener bleiben am Kartenobjekt haengen, auch wenn
|
||||||
if (!map || !ready) return
|
* 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 data = geojson
|
||||||
const color = trailStatusColor(status)
|
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 (!data) {
|
||||||
if (src) {
|
src?.setData({ type: 'FeatureCollection', features: [] })
|
||||||
src.setData({ type: 'FeatureCollection', features: [] })
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,33 +267,46 @@
|
||||||
|
|
||||||
if (src) {
|
if (src) {
|
||||||
src.setData(feature)
|
src.setData(feature)
|
||||||
map.setPaintProperty('trail-line', 'line-color', color)
|
instance.setPaintProperty('trail-line', 'line-color', color)
|
||||||
} else {
|
return
|
||||||
map.addSource('trail', { type: 'geojson', data: feature })
|
}
|
||||||
map.addLayer({
|
|
||||||
|
instance.addSource('trail', { type: 'geojson', data: feature })
|
||||||
|
instance.addLayer({
|
||||||
id: 'trail-line',
|
id: 'trail-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'trail',
|
source: 'trail',
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
paint: { 'line-color': color, 'line-width': 4 },
|
paint: { 'line-color': color, 'line-width': 4 },
|
||||||
})
|
})
|
||||||
map.addLayer({
|
instance.addLayer({
|
||||||
id: 'trail-hit',
|
id: 'trail-hit',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'trail',
|
source: 'trail',
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 },
|
paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 },
|
||||||
})
|
})
|
||||||
bindTrailEvents(map)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Beim Themenwechsel die Kachelquelle austauschen. */
|
if (!trailEventsBound) {
|
||||||
|
bindTrailEvents(instance)
|
||||||
|
trailEventsBound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trail-Linie zeichnen bzw. aktualisieren
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!map || !ready) return
|
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
|
// Kartenausschnitt auf den Track setzen
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { getContext, setContext } from 'svelte'
|
import { getContext, setContext } from 'svelte'
|
||||||
import { api, auth } from './pocketbase.svelte'
|
import { api, auth } from './pocketbase.svelte'
|
||||||
import { getEventContext } from './events.svelte'
|
import { getEventContext } from './events.svelte'
|
||||||
import { getRiderContext } from './riders.svelte'
|
import { getRiderContext, riderName } from './riders.svelte'
|
||||||
import type { EventParticipantsResponse, RidersResponse } from '$lib/types'
|
import type { EventParticipantsResponse, RidersResponse } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('eventParticipant')
|
const KEY = Symbol('eventParticipant')
|
||||||
|
|
@ -45,7 +45,7 @@ export class EventParticipantStore {
|
||||||
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb
|
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb
|
||||||
if (Number.isFinite(na)) return -1
|
if (Number.isFinite(na)) return -1
|
||||||
if (Number.isFinite(nb)) return 1
|
if (Number.isFinite(nb)) return 1
|
||||||
return this.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 {
|
riderLabel(eventId: string, riderId: string | undefined): string {
|
||||||
if (!riderId) return 'Unbekannter Fahrer'
|
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)
|
const number = this.numberOf(eventId, riderId)
|
||||||
|
|
||||||
return number ? `#${number} ${name}` : name
|
return number ? `#${number} ${name}` : name
|
||||||
}
|
}
|
||||||
|
|
||||||
private riderName(riderId: string): string {
|
private nameOf(riderId: string): string {
|
||||||
const r = this.riders.getById(riderId)
|
const r = this.riders.getById(riderId)
|
||||||
if (!r) return ''
|
return r ? riderName(r) : ''
|
||||||
return [r.firstname, r.lastname].filter(Boolean).join(' ') || r.name || ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,19 @@ class AuthStore {
|
||||||
api.authStore.loadFromCookie(cookie)
|
api.authStore.loadFromCookie(cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmelden. Ohne Argumente gelten die Felder des Stores, die das
|
||||||
|
* Login-Formular füllt; mit Argumenten die übergebenen — die
|
||||||
|
* Einladungsseite meldet damit das eben angelegte Konto an, ohne den
|
||||||
|
* Umweg über die Formularfelder.
|
||||||
|
*/
|
||||||
login = async (user = auth.username, password = auth.password) => {
|
login = async (user = auth.username, password = auth.password) => {
|
||||||
|
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = ''
|
this.error = ''
|
||||||
|
|
||||||
|
|
||||||
if (this.error) {
|
|
||||||
this.loading = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.collection('users').authWithPassword(auth.username, auth.password, this.params)
|
await api.collection('users').authWithPassword(user, password, this.params)
|
||||||
|
|
||||||
this.user = api.authStore.record as AuthRecord | null
|
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.
|
* Die gelten seit 1754501300 je Event und stehen am Teilnehmer.
|
||||||
*/
|
*/
|
||||||
function sortByName(records: RidersResponse[]) {
|
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)
|
* Der Name eines Fahrers — ein Feld, ein Wert.
|
||||||
return parts.join(' ').trim() || r.name || ''
|
*
|
||||||
|
* Bis 1754501800_riders_single_name.js standen hier Vorname und Nachname
|
||||||
|
* getrennt und mussten überall wieder zusammengesetzt werden. Die Funktion
|
||||||
|
* bleibt trotzdem: Sie ist die eine Stelle, an der ein namenloser Fahrer zu
|
||||||
|
* einem leeren String wird, statt als `undefined` durch die Oberfläche zu
|
||||||
|
* geistern.
|
||||||
|
*/
|
||||||
|
export function riderName(r: RidersResponse) {
|
||||||
|
return (r.name ?? '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RiderStore {
|
export class RiderStore {
|
||||||
|
|
@ -84,13 +92,47 @@ export class RiderStore {
|
||||||
return await api.collection('riders').update(id, data)
|
return await api.collection('riders').update(id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fahrer löschen — und mit ihm die Mitgliedschaft seines Kontos.
|
||||||
|
*
|
||||||
|
* Ohne den zweiten Schritt riss jedes Löschen ein Loch: Der Fahrer war
|
||||||
|
* fort, sein Konto blieb in `teams.users` und stand danach als Zeile ohne
|
||||||
|
* Fahrer im Kader. Aus so einer Zeile führte kein Weg zurück — ein Konto
|
||||||
|
* nachträglich an einen Fahrer zu hängen gibt es bewusst nicht, weil
|
||||||
|
* dieselbe Person sonst zweimal in der Liste stehen könnte. Der Eintrag
|
||||||
|
* war damit unreparierbar.
|
||||||
|
*
|
||||||
|
* Das Konto selbst bleibt bestehen: Löschen darf es allein sein Inhaber
|
||||||
|
* (`users.deleteRule = id = @request.auth.id`), und über einen neuen
|
||||||
|
* Einladungslink kommt es zurück ins Team.
|
||||||
|
*
|
||||||
|
* Der Owner ist ausgenommen. Ihn aus `teams.users` zu nehmen, nähme ihm
|
||||||
|
* die Sicht auf sein eigenes Team — die listRule der meisten Collections
|
||||||
|
* hängt an `team.users`, nicht an `team.owner`.
|
||||||
|
*/
|
||||||
remove(record: RidersResponse) {
|
remove(record: RidersResponse) {
|
||||||
|
const team = this.teams.records.find((t) => t.id === record.team)
|
||||||
|
const userId = record.user || null
|
||||||
|
const dropsMembership = !!userId && !!team && userId !== team.owner
|
||||||
|
|
||||||
|
const text = dropsMembership
|
||||||
|
? `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht. ` +
|
||||||
|
`Das zugehörige Konto verliert seine Mitgliedschaft im Team — gelöscht wird es nicht, es kann über einen Einladungslink zurückkommen.`
|
||||||
|
: `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
app.confirm.request({
|
app.confirm.request({
|
||||||
title: 'Fahrer löschen?',
|
title: 'Fahrer löschen?',
|
||||||
text: `Möchten Sie „${fullName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`,
|
text,
|
||||||
yes: async () => {
|
yes: async () => {
|
||||||
try {
|
try {
|
||||||
|
// Erst die Mitgliedschaft, dann der Fahrer. Andersherum
|
||||||
|
// stünde bei einem Fehler im zweiten Schritt genau das
|
||||||
|
// verwaiste Konto da, das hier verhindert werden soll.
|
||||||
|
if (dropsMembership) {
|
||||||
|
await this.teams.removeMember(team!.id, userId!)
|
||||||
|
}
|
||||||
|
|
||||||
await api.collection('riders').delete(record.id)
|
await api.collection('riders').delete(record.id)
|
||||||
app.confirm.close()
|
app.confirm.close()
|
||||||
resolve()
|
resolve()
|
||||||
|
|
@ -116,9 +158,14 @@ export class RiderStore {
|
||||||
return this.scoped.filter((r) => r.user === userId)
|
return this.scoped.filter((r) => r.user === userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bestehendes Konto zuordnen oder die Zuordnung lösen. */
|
/**
|
||||||
async linkUser(riderId: string, userId: string | null) {
|
* Konto an den Fahrer hängen. Nur intern und nur in diese Richtung: Ein
|
||||||
return await this.edit(riderId, { user: userId ?? '' } as Partial<RidersResponse>)
|
* 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> {
|
): Promise<UsersResponse> {
|
||||||
const user = await this.teams.createMember({
|
const user = await this.teams.createMember({
|
||||||
...data,
|
...data,
|
||||||
name: data.name?.trim() || fullName(rider),
|
name: data.name?.trim() || riderName(rider),
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.linkUser(rider.id, user.id)
|
await this.linkUser(rider.id, user.id)
|
||||||
|
|
@ -142,13 +189,7 @@ export class RiderStore {
|
||||||
get filtered() {
|
get filtered() {
|
||||||
const q = this.searchQuery.toLowerCase().trim()
|
const q = this.searchQuery.toLowerCase().trim()
|
||||||
if (!q) return this.scoped
|
if (!q) return this.scoped
|
||||||
return this.scoped.filter((r) => {
|
return this.scoped.filter((r) => riderName(r).toLowerCase().includes(q))
|
||||||
return (
|
|
||||||
(r.firstname ?? '').toLowerCase().includes(q) ||
|
|
||||||
(r.lastname ?? '').toLowerCase().includes(q) ||
|
|
||||||
(r.name ?? '').toLowerCase().includes(q)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,19 @@ import { getContext, setContext } from 'svelte'
|
||||||
import { api } from './pocketbase.svelte'
|
import { api } from './pocketbase.svelte'
|
||||||
import { app } from './app.svelte'
|
import { app } from './app.svelte'
|
||||||
import { getTeamContext } from './teams.svelte'
|
import { getTeamContext } from './teams.svelte'
|
||||||
import type { RunsResponse } from '$lib/types'
|
import type { StagesResponse } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('run')
|
const KEY = Symbol('stage')
|
||||||
|
|
||||||
export class RunStore {
|
export class StageStore {
|
||||||
records = $state<RunsResponse[]>([])
|
records = $state<StagesResponse[]>([])
|
||||||
loading = $state(false)
|
loading = $state(false)
|
||||||
error = $state<string | null>(null)
|
error = $state<string | null>(null)
|
||||||
|
|
||||||
private unsubscribe: (() => void) | null = null
|
private unsubscribe: (() => void) | null = null
|
||||||
private teams = getTeamContext()
|
private teams = getTeamContext()
|
||||||
|
|
||||||
get scoped(): RunsResponse[] {
|
get scoped(): StagesResponse[] {
|
||||||
const teamId = this.teams.activeId
|
const teamId = this.teams.activeId
|
||||||
if (!teamId) return []
|
if (!teamId) return []
|
||||||
return this.records.filter((r) => r.team === teamId)
|
return this.records.filter((r) => r.team === teamId)
|
||||||
|
|
@ -24,12 +24,12 @@ export class RunStore {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
try {
|
try {
|
||||||
this.records = await api.collection('runs').getFullList({
|
this.records = await api.collection('stages').getFullList({
|
||||||
sort: '+created',
|
sort: '+created',
|
||||||
requestKey: null,
|
requestKey: null,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.error = e.message ?? 'Fehler beim Laden der Runs'
|
this.error = e.message ?? 'Fehler beim Laden der Stages'
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
|
@ -38,34 +38,34 @@ export class RunStore {
|
||||||
|
|
||||||
subscribe() {
|
subscribe() {
|
||||||
if (this.unsubscribe) return
|
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)
|
const idx = this.records.findIndex((r) => r.id === e.record.id)
|
||||||
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record]
|
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record]
|
||||||
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
|
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
|
||||||
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
|
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
|
||||||
}).then((unsub) => {
|
}).then((unsub) => {
|
||||||
this.unsubscribe = unsub
|
this.unsubscribe = unsub
|
||||||
}).catch((e) => console.warn('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
|
const teamId = this.teams.activeId
|
||||||
if (!teamId) throw new Error('Kein aktives Team')
|
if (!teamId) throw new Error('Kein aktives Team')
|
||||||
return await api.collection('runs').create({ ...data, team: teamId })
|
return await api.collection('stages').create({ ...data, team: teamId })
|
||||||
}
|
}
|
||||||
|
|
||||||
async edit(id: string, data: Partial<RunsResponse>) {
|
async edit(id: string, data: Partial<StagesResponse>) {
|
||||||
return await api.collection('runs').update(id, data)
|
return await api.collection('stages').update(id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(record: RunsResponse) {
|
remove(record: StagesResponse) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
app.confirm.request({
|
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.`,
|
text: `Möchten Sie „${record.description}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`,
|
||||||
yes: async () => {
|
yes: async () => {
|
||||||
try {
|
try {
|
||||||
await api.collection('runs').delete(record.id)
|
await api.collection('stages').delete(record.id)
|
||||||
app.confirm.close()
|
app.confirm.close()
|
||||||
resolve()
|
resolve()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -97,12 +97,12 @@ export class RunStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRunContext() {
|
export function setStageContext() {
|
||||||
const store = new RunStore()
|
const store = new StageStore()
|
||||||
setContext(KEY, store)
|
setContext(KEY, store)
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRunContext(): RunStore {
|
export function getStageContext(): StageStore {
|
||||||
return getContext<RunStore>(KEY)
|
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
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
/** User per E-Mail suchen (für Einladungs-UI) */
|
|
||||||
async findUserByEmail(email: string): Promise<UsersResponse | null> {
|
|
||||||
try {
|
|
||||||
const res = await api.collection('users').getFirstListItem(
|
|
||||||
`email = "${email.replaceAll('"', '')}"`,
|
|
||||||
{ requestKey: null },
|
|
||||||
)
|
|
||||||
return res
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.unsubscribe) {
|
if (this.unsubscribe) {
|
||||||
this.unsubscribe()
|
this.unsubscribe()
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,11 @@ export class TimeStore {
|
||||||
}).catch((e) => console.warn('times subscribe failed:', e))
|
}).catch((e) => console.warn('times subscribe failed:', e))
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(runId: string, riderId: string) {
|
async start(stageId: string, riderId: string) {
|
||||||
const teamId = this.teams.activeId
|
const teamId = this.teams.activeId
|
||||||
if (!teamId) throw new Error('Kein aktives Team')
|
if (!teamId) throw new Error('Kein aktives Team')
|
||||||
return await api.collection('times').create({
|
return await api.collection('times').create({
|
||||||
run: runId,
|
stage: stageId,
|
||||||
rider: riderId,
|
rider: riderId,
|
||||||
team: teamId,
|
team: teamId,
|
||||||
start: new Date().toISOString(),
|
start: new Date().toISOString(),
|
||||||
|
|
@ -128,17 +128,17 @@ export class TimeStore {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getByRun(runId: string, now = Date.now()): TimeWithDuration[] {
|
getByStage(stageId: string, now = Date.now()): TimeWithDuration[] {
|
||||||
return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now))
|
return this.scoped.filter((r) => r.stage === stageId).map((r) => enrich(r, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
|
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
|
||||||
return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now))
|
return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Laufende Zeiten eines Runs — der am längsten laufende zuerst. */
|
/** Laufende Zeiten einer Stage — die am längsten laufende zuerst. */
|
||||||
runningByRun(runId: string, now = Date.now()): TimeWithDuration[] {
|
runningByStage(stageId: string, now = Date.now()): TimeWithDuration[] {
|
||||||
return this.getByRun(runId, now)
|
return this.getByStage(stageId, now)
|
||||||
.filter((r) => r.status === 'active')
|
.filter((r) => r.status === 'active')
|
||||||
.sort((a, b) => (a.start < b.start ? -1 : 1))
|
.sort((a, b) => (a.start < b.start ? -1 : 1))
|
||||||
}
|
}
|
||||||
|
|
@ -147,21 +147,21 @@ export class TimeStore {
|
||||||
* Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf,
|
* Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf,
|
||||||
* dürfen aber auch nicht unsichtbar werden.
|
* dürfen aber auch nicht unsichtbar werden.
|
||||||
*/
|
*/
|
||||||
unrankedByRun(runId: string): TimeWithDuration[] {
|
unrankedByStage(stageId: string): TimeWithDuration[] {
|
||||||
return this.getByRun(runId).filter(
|
return this.getByStage(stageId).filter(
|
||||||
(r) => r.status !== 'active' && r.status !== 'finished',
|
(r) => r.status !== 'active' && r.status !== 'finished',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
isRiderActive(runId: string, riderId: string) {
|
isRiderActive(stageId: string, riderId: string) {
|
||||||
return this.scoped.some(
|
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(
|
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
|
* Alle laufenden Zeiten des Teams, stage-übergreifend — der am längsten
|
||||||
* laufende zuerst. `now` wie bei `getByRun`, damit die Anzeige mitzählt.
|
* laufende zuerst. `now` wie bei `getByStage`, damit die Anzeige mitzählt.
|
||||||
*/
|
*/
|
||||||
allRunning(now = Date.now()): TimeWithDuration[] {
|
allRunning(now = Date.now()): TimeWithDuration[] {
|
||||||
return this.scoped
|
return this.scoped
|
||||||
|
|
@ -185,15 +185,15 @@ export class TimeStore {
|
||||||
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
|
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
getLeaderboard(runId: string): TimeWithDuration[] {
|
getLeaderboard(stageId: string): TimeWithDuration[] {
|
||||||
return this.getByRun(runId)
|
return this.getByStage(stageId)
|
||||||
.filter((r) => r.status === 'finished' && r.duration !== undefined)
|
.filter((r) => r.status === 'finished' && r.duration !== undefined)
|
||||||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
|
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Platz eines Fahrers im Run, 1-basiert; null ohne gewertete Zeit. */
|
/** Platz eines Fahrers im Stage, 1-basiert; null ohne gewertete Zeit. */
|
||||||
placement(runId: string, riderId: string): number | null {
|
placement(stageId: string, riderId: string): number | null {
|
||||||
const idx = this.getLeaderboard(runId).findIndex((t) => t.rider === riderId)
|
const idx = this.getLeaderboard(stageId).findIndex((t) => t.rider === riderId)
|
||||||
return idx === -1 ? null : idx + 1
|
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
|
* Formatierung von Zeiten. Bewusst hier und nicht im times-Store: Auch
|
||||||
* Komponenten ohne Store-Zugriff zeigen Dauern an, und die Darstellung soll
|
* Komponenten ohne Store-Zugriff zeigen Dauern an, und die Darstellung soll
|
||||||
|
|
@ -23,10 +26,39 @@ export const TIME_STATUS_LABEL: Record<string, string> = {
|
||||||
dsq: 'DSQ',
|
dsq: 'DSQ',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein PocketBase-Zeitstempel als Date. Die kommen als
|
||||||
|
* `2026-09-12 08:30:00.000Z` mit Leerzeichen statt `T` — die meisten Browser
|
||||||
|
* schlucken das, garantiert ist es nicht.
|
||||||
|
*/
|
||||||
|
function parseDate(value: string | undefined): Date | null {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const date = new Date(value.replace(' ', 'T'))
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
/** Zeitpunkt als `TT.MM.JJJJ, HH:MM`. */
|
/** Zeitpunkt als `TT.MM.JJJJ, HH:MM`. */
|
||||||
export function formatDateTime(iso: string | undefined): string {
|
export function formatDateTime(iso: string | undefined): string {
|
||||||
if (!iso) return '—'
|
const date = parseDate(iso)
|
||||||
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
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). */
|
/** 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",
|
EventSeries = "event_series",
|
||||||
Events = "events",
|
Events = "events",
|
||||||
Riders = "riders",
|
Riders = "riders",
|
||||||
Runs = "runs",
|
Stages = "stages",
|
||||||
|
TeamInvites = "team_invites",
|
||||||
Teams = "teams",
|
Teams = "teams",
|
||||||
Times = "times",
|
Times = "times",
|
||||||
TrailComments = "trail_comments",
|
TrailComments = "trail_comments",
|
||||||
|
|
@ -116,16 +117,14 @@ export type EventsRecord = {
|
||||||
export type RidersRecord = {
|
export type RidersRecord = {
|
||||||
avatar?: string
|
avatar?: string
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
firstname?: string
|
|
||||||
id: string
|
id: string
|
||||||
lastname?: string
|
|
||||||
name?: string
|
name?: string
|
||||||
team?: RecordIdString
|
team?: RecordIdString
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
user?: RecordIdString
|
user?: RecordIdString
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunsRecord = {
|
export type StagesRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
description?: string
|
description?: string
|
||||||
event?: RecordIdString
|
event?: RecordIdString
|
||||||
|
|
@ -134,6 +133,18 @@ export type RunsRecord = {
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TeamInvitesRecord = {
|
||||||
|
created?: IsoDateString
|
||||||
|
created_by?: RecordIdString
|
||||||
|
expires?: IsoDateString
|
||||||
|
id: string
|
||||||
|
max_uses?: number
|
||||||
|
team: RecordIdString
|
||||||
|
token: string
|
||||||
|
updated?: IsoDateString
|
||||||
|
uses?: number
|
||||||
|
}
|
||||||
|
|
||||||
export type TeamsRecord = {
|
export type TeamsRecord = {
|
||||||
admins?: RecordIdString[]
|
admins?: RecordIdString[]
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
|
|
@ -158,7 +169,7 @@ export type TimesRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
id: string
|
id: string
|
||||||
rider?: RecordIdString
|
rider?: RecordIdString
|
||||||
run?: RecordIdString
|
stage?: RecordIdString
|
||||||
start?: IsoDateString
|
start?: IsoDateString
|
||||||
startedBy?: RecordIdString
|
startedBy?: RecordIdString
|
||||||
status?: TimesStatusOptions
|
status?: TimesStatusOptions
|
||||||
|
|
@ -267,7 +278,8 @@ export type EventParticipantsResponse<Texpand = unknown> = Required<EventPartici
|
||||||
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
|
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
|
||||||
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
||||||
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
|
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
|
||||||
export type 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 TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
|
||||||
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand>
|
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand>
|
||||||
export type TrailCommentsResponse<Texpand = unknown> = Required<TrailCommentsRecord> & BaseSystemFields<Texpand>
|
export type TrailCommentsResponse<Texpand = unknown> = Required<TrailCommentsRecord> & BaseSystemFields<Texpand>
|
||||||
|
|
@ -284,7 +296,8 @@ export type CollectionRecords = {
|
||||||
event_series: EventSeriesRecord
|
event_series: EventSeriesRecord
|
||||||
events: EventsRecord
|
events: EventsRecord
|
||||||
riders: RidersRecord
|
riders: RidersRecord
|
||||||
runs: RunsRecord
|
stages: StagesRecord
|
||||||
|
team_invites: TeamInvitesRecord
|
||||||
teams: TeamsRecord
|
teams: TeamsRecord
|
||||||
times: TimesRecord
|
times: TimesRecord
|
||||||
trail_comments: TrailCommentsRecord
|
trail_comments: TrailCommentsRecord
|
||||||
|
|
@ -300,7 +313,8 @@ export type CollectionResponses = {
|
||||||
event_series: EventSeriesResponse
|
event_series: EventSeriesResponse
|
||||||
events: EventsResponse
|
events: EventsResponse
|
||||||
riders: RidersResponse
|
riders: RidersResponse
|
||||||
runs: RunsResponse
|
stages: StagesResponse
|
||||||
|
team_invites: TeamInvitesResponse
|
||||||
teams: TeamsResponse
|
teams: TeamsResponse
|
||||||
times: TimesResponse
|
times: TimesResponse
|
||||||
trail_comments: TrailCommentsResponse
|
trail_comments: TrailCommentsResponse
|
||||||
|
|
@ -319,7 +333,8 @@ export type TypedPocketBase = PocketBase & {
|
||||||
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
||||||
collection(idOrName: 'events'): RecordService<EventsResponse>
|
collection(idOrName: 'events'): RecordService<EventsResponse>
|
||||||
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
||||||
collection(idOrName: 'runs'): RecordService<RunsResponse>
|
collection(idOrName: 'stages'): RecordService<StagesResponse>
|
||||||
|
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
|
||||||
collection(idOrName: 'teams'): RecordService<TeamsResponse>
|
collection(idOrName: 'teams'): RecordService<TeamsResponse>
|
||||||
collection(idOrName: 'times'): RecordService<TimesResponse>
|
collection(idOrName: 'times'): RecordService<TimesResponse>
|
||||||
collection(idOrName: 'trail_comments'): RecordService<TrailCommentsResponse>
|
collection(idOrName: 'trail_comments'): RecordService<TrailCommentsResponse>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import {auth} from '$lib/stores/pocketbase.svelte'
|
import {auth} from '$lib/stores/pocketbase.svelte'
|
||||||
import {goto} from '$app/navigation'
|
import {goto} from '$app/navigation'
|
||||||
import {onMount} from 'svelte'
|
import {onMount} from 'svelte'
|
||||||
import {LoaderCircle} from 'lucide-svelte'
|
import Spinner from '$lib/components/Spinner.svelte'
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
goto(auth.user ? '/dashboard' : '/login', {replaceState: true})
|
goto(auth.user ? '/dashboard' : '/login', {replaceState: true})
|
||||||
|
|
@ -10,5 +10,5 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
<LoaderCircle class="animate-spin" size="64"/>
|
<Spinner class="size-16"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { auth } from '$lib/stores/pocketbase.svelte'
|
import { auth } from '$lib/stores/pocketbase.svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { 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 { setTeamContext } from '$lib/stores/teams.svelte'
|
||||||
|
import { setTeamInviteContext } from '$lib/stores/teamInvites.svelte'
|
||||||
import { setEventContext } from '$lib/stores/events.svelte'
|
import { setEventContext } from '$lib/stores/events.svelte'
|
||||||
import { setRunContext } from '$lib/stores/runs.svelte'
|
import { setStageContext } from '$lib/stores/stages.svelte'
|
||||||
import { setRiderContext } from '$lib/stores/riders.svelte'
|
import { setRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { setTimeContext } from '$lib/stores/times.svelte'
|
import { setTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
|
|
@ -17,10 +19,10 @@
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import Logo from '$lib/components/Logo.svelte'
|
import Logo from '$lib/components/Logo.svelte'
|
||||||
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
|
import TeamMenu from '$lib/components/TeamMenu.svelte'
|
||||||
import Avatar from '$lib/components/Avatar.svelte'
|
import Avatar from '$lib/components/Avatar.svelte'
|
||||||
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
||||||
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, 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 { toggleMode, mode } from 'mode-watcher'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
|
||||||
|
|
@ -29,8 +31,9 @@
|
||||||
// Teams MUSS vor den anderen Stores stehen, weil die anderen Stores
|
// Teams MUSS vor den anderen Stores stehen, weil die anderen Stores
|
||||||
// getTeamContext() im Constructor aufrufen
|
// getTeamContext() im Constructor aufrufen
|
||||||
const teams = setTeamContext()
|
const teams = setTeamContext()
|
||||||
|
const invites = setTeamInviteContext()
|
||||||
const events = setEventContext()
|
const events = setEventContext()
|
||||||
const runs = setRunContext()
|
const stages = setStageContext()
|
||||||
const riders = setRiderContext()
|
const riders = setRiderContext()
|
||||||
const times = setTimeContext()
|
const times = setTimeContext()
|
||||||
// Nach events und riders: Der Store greift im Constructor auf beide zu.
|
// Nach events und riders: Der Store greift im Constructor auf beide zu.
|
||||||
|
|
@ -53,12 +56,14 @@
|
||||||
await teams.load()
|
await teams.load()
|
||||||
teams.subscribe()
|
teams.subscribe()
|
||||||
await Promise.all([
|
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(),
|
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
|
||||||
trailComments.load(),
|
trailComments.load(),
|
||||||
])
|
])
|
||||||
|
invites.subscribe()
|
||||||
events.subscribe()
|
events.subscribe()
|
||||||
runs.subscribe()
|
stages.subscribe()
|
||||||
riders.subscribe()
|
riders.subscribe()
|
||||||
times.subscribe()
|
times.subscribe()
|
||||||
participants.subscribe()
|
participants.subscribe()
|
||||||
|
|
@ -72,8 +77,9 @@
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
teams.destroy()
|
teams.destroy()
|
||||||
|
invites.destroy()
|
||||||
events.destroy()
|
events.destroy()
|
||||||
runs.destroy()
|
stages.destroy()
|
||||||
riders.destroy()
|
riders.destroy()
|
||||||
times.destroy()
|
times.destroy()
|
||||||
participants.destroy()
|
participants.destroy()
|
||||||
|
|
@ -85,25 +91,54 @@
|
||||||
trailComments.destroy()
|
trailComments.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleLogout() {
|
// Ohne Rückfrage: Abmelden löscht nichts und ist mit einem Login wieder
|
||||||
app.confirm.request({
|
// rückgängig gemacht — eine Bestätigung wäre nur ein zusätzlicher Klick.
|
||||||
title: 'Abmelden?',
|
async function handleLogout() {
|
||||||
text: 'Möchten Sie sich wirklich abmelden?',
|
|
||||||
yes: async () => {
|
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
app.confirm.close()
|
|
||||||
goto('/login')
|
goto('/login')
|
||||||
},
|
|
||||||
no: () => app.confirm.close(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Menuepunkt sieht aus wie der andere, ob Link oder aufklappbar.
|
||||||
|
const navItemClass =
|
||||||
|
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors'
|
||||||
|
const navIdleClass = 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
const navHoverClass = 'text-foreground bg-accent'
|
||||||
|
const navActiveClass = 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wo man gerade steht. „/dashboard" nur genau dort — sonst waere das
|
||||||
|
* Dashboard auf jeder Unterseite mit-aktiv, weil alle Pfade damit
|
||||||
|
* anfangen.
|
||||||
|
*/
|
||||||
|
function isActive(href: string) {
|
||||||
|
const path = page.url.pathname
|
||||||
|
return href === '/dashboard' ? path === '/dashboard' : path.startsWith(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die vollstaendige Klassenliste eines Menuepunkts. Bewusst eine
|
||||||
|
* Funktion und kein zusaetzliches `class:`: `text-foreground` und
|
||||||
|
* `text-muted-foreground` sind dieselbe Utility-Gruppe — stuenden beide
|
||||||
|
* am Element, entschiede die Reihenfolge im Stylesheet und nicht die im
|
||||||
|
* Attribut.
|
||||||
|
*/
|
||||||
|
function navClass(href: string, hovered = false) {
|
||||||
|
if (isActive(href)) return `${navItemClass} ${navActiveClass}`
|
||||||
|
return `${navItemClass} ${hovered ? navHoverClass : navIdleClass}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logo und Menuepunkt „Dashboard" fuehren an dieselbe Stelle, also
|
||||||
|
// reagieren sie gemeinsam — egal, welches von beiden man ueberfaehrt.
|
||||||
|
// Ueber CSS ginge das nicht: Der Menuepunkt steckt in der <nav>, das Logo
|
||||||
|
// daneben, und peer-hover reicht nur an Geschwister, nicht in sie hinein.
|
||||||
|
let dashHover = $state(false)
|
||||||
|
|
||||||
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
|
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
|
||||||
// passiert dort, wo es hingehört — am Run innerhalb seines Events.
|
// passiert dort, wo es hingehört — an der Stage innerhalb ihres Events.
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
||||||
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
||||||
{ href: '/dashboard/riders', icon: Bike, label: 'Fahrer' },
|
{ href: '/dashboard/team', icon: Users, label: 'Team', submenu: true },
|
||||||
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
|
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
|
||||||
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
|
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
|
||||||
]
|
]
|
||||||
|
|
@ -121,38 +156,54 @@
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none fixed -bottom-24 -right-24 z-0 select-none opacity-[0.04] dark:opacity-[0.07]"
|
class="pointer-events-none fixed -bottom-24 -right-24 z-0 select-none opacity-[0.04] dark:opacity-[0.07]"
|
||||||
>
|
>
|
||||||
|
|
||||||
<Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" />
|
<Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header class="relative z-10 border-b bg-card">
|
<header class="relative z-10 border-b bg-card">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
|
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
|
||||||
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
|
<!-- Links: Logo und Menü. Das negative -my-3 frisst das
|
||||||
voller Groesse wuerde es die 64 px hohe Leiste
|
py-2 der Leiste auf und noch etwas mehr: Das Logo
|
||||||
ueberragen und beschnitten wirken. -->
|
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">
|
<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}
|
{#each nav as item}
|
||||||
{@const Icon = item.icon}
|
{@const Icon = item.icon}
|
||||||
|
{#if item.submenu}
|
||||||
|
<TeamMenu class={navClass(item.href)} />
|
||||||
|
{:else}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="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" />
|
<Icon class="h-4 w-4" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
|
<!-- Rechts: wer man ist, und was man mit sich tun kann.
|
||||||
Zeile, das Team-Dropdown darunter. -->
|
Das Team steht jetzt im Menue links. -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Wer man ist, steht links; was man tun kann, rechts. -->
|
<div class="hidden md:flex min-w-0 items-center gap-3">
|
||||||
<div class="hidden md:flex min-w-56 flex-col gap-1">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<a
|
<a
|
||||||
href="/dashboard/settings"
|
href="/dashboard/settings"
|
||||||
class="flex min-w-0 items-center gap-2"
|
class="flex min-w-0 items-center gap-2"
|
||||||
|
|
@ -190,11 +241,6 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex">
|
|
||||||
<TeamSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -217,14 +263,21 @@
|
||||||
<nav class="px-2 py-2 space-y-1">
|
<nav class="px-2 py-2 space-y-1">
|
||||||
{#each nav as item}
|
{#each nav as item}
|
||||||
{@const Icon = item.icon}
|
{@const Icon = item.icon}
|
||||||
|
{#if item.submenu}
|
||||||
|
<TeamMenu
|
||||||
|
class="w-full {navClass(item.href)}"
|
||||||
|
onnavigate={() => (isMobileMenuOpen = false)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onclick={() => (isMobileMenuOpen = false)}
|
onclick={() => (isMobileMenuOpen = false)}
|
||||||
class="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" />
|
<Icon class="h-4 w-4" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<Separator class="my-2" />
|
<Separator class="my-2" />
|
||||||
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
||||||
|
|
@ -235,7 +288,6 @@
|
||||||
{auth.user.name || auth.user.email}
|
{auth.user.name || auth.user.email}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<TeamSwitcher />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln">
|
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getRiderContext } from '$lib/stores/riders.svelte'
|
import { getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
const teams = getTeamContext()
|
const teams = getTeamContext()
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
|
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
{#each upNext as event}
|
{#each upNext as event}
|
||||||
{@const eventRuns = runs.getByEvent(event.id).length}
|
{@const eventStages = stages.getByEvent(event.id).length}
|
||||||
<Card class="hover:border-primary transition-colors">
|
<Card class="hover:border-primary transition-colors">
|
||||||
<a href="/dashboard/events/{event.id}" class="block">
|
<a href="/dashboard/events/{event.id}" class="block">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p class="text-sm text-muted-foreground">{eventRuns} Run{eventRuns === 1 ? '' : 's'}</p>
|
<p class="text-sm text-muted-foreground">{eventStages} Stage{eventStages === 1 ? '' : 's'}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
import {
|
import {
|
||||||
getEventSeriesContext,
|
getEventSeriesContext,
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
import type { EventsResponse } from '$lib/types'
|
import type { EventsResponse } from '$lib/types'
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
const series = getEventSeriesContext()
|
const series = getEventSeriesContext()
|
||||||
|
|
||||||
|
|
@ -244,7 +244,7 @@
|
||||||
dayOf(s.until) || undefined,
|
dayOf(s.until) || undefined,
|
||||||
)}
|
)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-4 space-y-2">
|
<CardContent class="space-y-2">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="font-medium truncate">{s.name}</p>
|
<p class="font-medium truncate">{s.name}</p>
|
||||||
|
|
@ -301,7 +301,7 @@
|
||||||
<h2 class="text-lg font-semibold">{group.title} <span class="text-muted-foreground font-normal">({group.items.length})</span></h2>
|
<h2 class="text-lg font-semibold">{group.title} <span class="text-muted-foreground font-normal">({group.items.length})</span></h2>
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each group.items as event}
|
{#each group.items as event}
|
||||||
{@const runCount = runs.getByEvent(event.id).length}
|
{@const stageCount = stages.getByEvent(event.id).length}
|
||||||
<Card class="hover:border-primary transition-colors">
|
<Card class="hover:border-primary transition-colors">
|
||||||
<a href="/dashboard/events/{event.id}" class="block">
|
<a href="/dashboard/events/{event.id}" class="block">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -328,7 +328,7 @@
|
||||||
{#if event.timing}
|
{#if event.timing}
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<Timer class="size-3.5" />
|
<Timer class="size-3.5" />
|
||||||
{runCount} Run{runCount === 1 ? '' : 's'}
|
{stageCount} Stage{stageCount === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
|
|
@ -341,7 +341,7 @@
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</a>
|
</a>
|
||||||
<div class="px-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">
|
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} tooltip="Bearbeiten">
|
||||||
<Pencil class="h-4 w-4" />
|
<Pencil class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -416,7 +416,7 @@
|
||||||
mit Zeitnahme
|
mit Zeitnahme
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-muted-foreground -mt-2">
|
<p class="text-xs text-muted-foreground -mt-2">
|
||||||
Blendet Runs und Zeitnahme ein. Für Stammtisch oder Feierabendrunde
|
Blendet Stages und Zeitnahme ein. Für Stammtisch oder Feierabendrunde
|
||||||
unnötig.
|
unnötig.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import {
|
import {
|
||||||
|
|
@ -36,10 +36,10 @@
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-svelte'
|
} from 'lucide-svelte'
|
||||||
import type { EventParticipantsResponse, RunsResponse } from '$lib/types'
|
import type { EventParticipantsResponse, StagesResponse } from '$lib/types'
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const teams = getTeamContext()
|
const teams = getTeamContext()
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
|
|
||||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||||
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
|
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
|
||||||
const 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 —
|
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
|
||||||
|
|
@ -65,14 +65,14 @@
|
||||||
!!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen',
|
!!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen',
|
||||||
)
|
)
|
||||||
|
|
||||||
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen 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.
|
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
|
||||||
function riderPath(riderId: string) {
|
function riderPath(riderId: string) {
|
||||||
return `/dashboard/events/${event?.id}/riders/${riderId}`
|
return `/dashboard/events/${event?.id}/riders/${riderId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPath(runId: string) {
|
function stagePath(stageId: string) {
|
||||||
return `/dashboard/events/${event?.id}/runs/${runId}`
|
return `/dashboard/events/${event?.id}/stages/${stageId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
||||||
|
|
@ -162,30 +162,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run-Dialog
|
// Stage-Dialog
|
||||||
let runDialog = $state(false)
|
let stageDialog = $state(false)
|
||||||
let runEdit = $state<RunsResponse | null>(null)
|
let stageEdit = $state<StagesResponse | null>(null)
|
||||||
let runForm = $state({ description: '' })
|
let stageForm = $state({ description: '' })
|
||||||
|
|
||||||
function openRunCreate() {
|
function openStageCreate() {
|
||||||
runEdit = null
|
stageEdit = null
|
||||||
runForm = { description: '' }
|
stageForm = { description: '' }
|
||||||
runDialog = true
|
stageDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRunEdit(r: RunsResponse) {
|
function openStageEdit(r: StagesResponse) {
|
||||||
runEdit = r
|
stageEdit = r
|
||||||
runForm = { description: r.description ?? '' }
|
stageForm = { description: r.description ?? '' }
|
||||||
runDialog = true
|
stageDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRun() {
|
async function saveStage() {
|
||||||
if (!event) return
|
if (!event) return
|
||||||
if (!runForm.description.trim()) return alert('Bezeichnung ist Pflicht')
|
if (!stageForm.description.trim()) return alert('Bezeichnung ist Pflicht')
|
||||||
try {
|
try {
|
||||||
if (runEdit) await runs.edit(runEdit.id, runForm)
|
if (stageEdit) await stages.edit(stageEdit.id, stageForm)
|
||||||
else await runs.create({ ...runForm, event: event.id })
|
else await stages.create({ ...stageForm, event: event.id })
|
||||||
runDialog = false
|
stageDialog = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(`Fehler: ${e.message}`)
|
alert(`Fehler: ${e.message}`)
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +236,7 @@
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onclick={() => (timeDialog = true)}
|
onclick={() => (timeDialog = true)}
|
||||||
disabled={eventParticipants.length === 0 || eventRuns.length === 0}
|
disabled={eventParticipants.length === 0 || eventStages.length === 0}
|
||||||
>
|
>
|
||||||
<Timer class="h-4 w-4 mr-2" />
|
<Timer class="h-4 w-4 mr-2" />
|
||||||
Zeit erfassen
|
Zeit erfassen
|
||||||
|
|
@ -283,7 +283,7 @@
|
||||||
{#if canManage && available.length === 0 && riders.scoped.length === 0}
|
{#if canManage && available.length === 0 && riders.scoped.length === 0}
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Das Team hat noch keine Fahrer.
|
Das Team hat noch keine Fahrer.
|
||||||
<a href="/dashboard/settings/riders" class="underline">Jetzt anlegen</a>
|
<a href="/dashboard/team" class="underline">Jetzt anlegen</a>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -322,8 +322,8 @@
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||||
<a href={riderPath(p.rider)} class="hover:underline">
|
<a href={riderPath(p.rider)} class="hover:underline">
|
||||||
{#if rider && fullName(rider)}
|
{#if rider && riderName(rider)}
|
||||||
{fullName(rider)}
|
{riderName(rider)}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -342,7 +342,7 @@
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{#if event.timing}
|
{#if event.timing}
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
{done} von {eventRuns.length} Runs
|
{done} von {eventStages.length} Stages
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{/if}
|
{/if}
|
||||||
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
||||||
|
|
@ -411,34 +411,34 @@
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Runs — nur bei Events mit Zeitnahme -->
|
<!-- Stages — nur bei Events mit Zeitnahme -->
|
||||||
{#if event.timing}
|
{#if event.timing}
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||||
<Timer class="h-5 w-5" />
|
<Timer class="h-5 w-5" />
|
||||||
Runs
|
Stages
|
||||||
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
|
<span class="text-muted-foreground font-normal text-base">({eventStages.length})</span>
|
||||||
</h2>
|
</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>
|
</div>
|
||||||
<Button onclick={openRunCreate}>
|
<Button onclick={openStageCreate}>
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
Run anlegen
|
Stage anlegen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
{#if eventRuns.length === 0}
|
{#if eventStages.length === 0}
|
||||||
<div class="py-12 text-center">
|
<div class="py-12 text-center">
|
||||||
<Timer class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
<Timer class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||||
<p class="font-medium">Noch keine 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>
|
<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" />
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
Ersten Run anlegen
|
Erste Stage anlegen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -451,13 +451,13 @@
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each eventRuns as run (run.id)}
|
{#each eventStages as stage (stage.id)}
|
||||||
{@const runTimes = times.getByRun(run.id)}
|
{@const stageTimes = times.getByStage(stage.id)}
|
||||||
{@const active = runTimes.filter((t) => t.status === 'active').length}
|
{@const active = stageTimes.filter((t) => t.status === 'active').length}
|
||||||
{@const ranked = runTimes.filter((t) => t.status === 'finished').length}
|
{@const ranked = stageTimes.filter((t) => t.status === 'finished').length}
|
||||||
<Table.Row class="cursor-pointer" onclick={() => goto(runPath(run.id))}>
|
<Table.Row class="cursor-pointer" onclick={() => goto(stagePath(stage.id))}>
|
||||||
<Table.Cell class="font-medium">
|
<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>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{#if active > 0}
|
{#if active > 0}
|
||||||
|
|
@ -466,10 +466,10 @@
|
||||||
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
|
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
||||||
<Button variant="ghost" size="icon" onclick={() => openRunEdit(run)}>
|
<Button variant="ghost" size="icon" onclick={() => openStageEdit(stage)}>
|
||||||
<Pencil class="h-4 w-4" />
|
<Pencil class="h-4 w-4" />
|
||||||
</Button>
|
</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" />
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
@ -507,12 +507,12 @@
|
||||||
<Select.Root type="single" bind:value={form.rider}>
|
<Select.Root type="single" bind:value={form.rider}>
|
||||||
<Select.Trigger class="w-full">
|
<Select.Trigger class="w-full">
|
||||||
{form.rider
|
{form.rider
|
||||||
? fullName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
|
? riderName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
|
||||||
: 'Fahrer wählen…'}
|
: 'Fahrer wählen…'}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each available as r (r.id)}
|
{#each available as r (r.id)}
|
||||||
<Select.Item value={r.id}>{fullName(r) || '— ohne Name —'}</Select.Item>
|
<Select.Item value={r.id}>{riderName(r) || '— ohne Name —'}</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
|
|
@ -539,29 +539,29 @@
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
<!-- Run-Dialog -->
|
<!-- Stage-Dialog -->
|
||||||
<Dialog.Root bind:open={runDialog}>
|
<Dialog.Root bind:open={stageDialog}>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{runEdit ? 'Run bearbeiten' : 'Neuer Run'}</Dialog.Title>
|
<Dialog.Title>{stageEdit ? 'Stage bearbeiten' : 'Neue Stage'}</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Run für „{event?.name}"
|
Stage für „{event?.name}"
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
<form
|
<form
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
saveRun()
|
saveStage()
|
||||||
}}
|
}}
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="run-desc">Bezeichnung *</Label>
|
<Label for="stage-desc">Bezeichnung *</Label>
|
||||||
<Input id="run-desc" bind:value={runForm.description} placeholder="Stage 1 – Downhill" required />
|
<Input id="stage-desc" bind:value={stageForm.description} placeholder="Stage 1 – Downhill" required />
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button type="button" variant="ghost" onclick={() => (runDialog = false)}>Abbrechen</Button>
|
<Button type="button" variant="ghost" onclick={() => (stageDialog = false)}>Abbrechen</Button>
|
||||||
<Button type="submit">{runEdit ? 'Speichern' : 'Anlegen'}</Button>
|
<Button type="submit">{stageEdit ? 'Speichern' : 'Anlegen'}</Button>
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
</form>
|
</form>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Fahrerübersicht: alle Runs des Events aus Sicht eines Fahrers. Gezeigt
|
* Fahrerübersicht: alle Stages des Events aus Sicht eines Fahrers. Gezeigt
|
||||||
* wird je Run seine Zeit, sein Platz und ob gerade gemessen wird — die
|
* wird je Stage seine Zeit, sein Platz und ob gerade gemessen wird — die
|
||||||
* Zeitnahme selbst passiert auf der Run-Seite, dorthin führt jede Zeile.
|
* Zeitnahme selbst passiert auf der Stage-Seite, dorthin führt jede Zeile.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
|
|
@ -37,40 +37,40 @@
|
||||||
const valid = $derived(!!event && !!rider && !!entry)
|
const valid = $derived(!!event && !!rider && !!entry)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eine Zeile je 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.
|
* bleibt, was dem Fahrer noch fehlt.
|
||||||
*/
|
*/
|
||||||
const rows = $derived.by(() => {
|
const rows = $derived.by(() => {
|
||||||
if (!valid || !event || !rider) return []
|
if (!valid || !event || !rider) return []
|
||||||
|
|
||||||
// clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste
|
// clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste
|
||||||
// aktuell; bei einer Handvoll Runs ist das billig.
|
// aktuell; bei einer Handvoll Stages ist das billig.
|
||||||
const own = times.getByRider(rider.id, clock.now)
|
const own = times.getByRider(rider.id, clock.now)
|
||||||
|
|
||||||
return runs.getByEvent(event.id).map((run) => {
|
return stages.getByEvent(event.id).map((stage) => {
|
||||||
const forRun = own.filter((t) => t.run === run.id)
|
const forStage = own.filter((t) => t.stage === stage.id)
|
||||||
const active = forRun.find((t) => t.status === 'active')
|
const active = forStage.find((t) => t.status === 'active')
|
||||||
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
|
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
|
||||||
const best = forRun
|
const best = forStage
|
||||||
.filter((t) => t.status === 'finished')
|
.filter((t) => t.status === 'finished')
|
||||||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
|
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
run,
|
stage,
|
||||||
active,
|
active,
|
||||||
best,
|
best,
|
||||||
others: forRun.filter((t) => t.status !== 'active' && t.status !== 'finished'),
|
others: forStage.filter((t) => t.status !== 'active' && t.status !== 'finished'),
|
||||||
placement: best ? times.placement(run.id, rider.id) : null,
|
placement: best ? times.placement(stage.id, rider.id) : null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
|
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
|
||||||
let timeDialog = $state(false)
|
let timeDialog = $state(false)
|
||||||
let dialogRun = $state<string | null>(null)
|
let dialogStage = $state<string | null>(null)
|
||||||
|
|
||||||
function openTimeDialog(runId: string | null = null) {
|
function openTimeDialog(stageId: string | null = null) {
|
||||||
dialogRun = runId
|
dialogStage = stageId
|
||||||
timeDialog = true
|
timeDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
})
|
})
|
||||||
</script>
|
</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}
|
{#if !valid}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
<Badge variant="outline" class="text-lg px-3 py-1">{entry.number}</Badge>
|
<Badge variant="outline" class="text-lg px-3 py-1">{entry.number}</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
<h1 class="text-3xl font-bold tracking-tight">
|
<h1 class="text-3xl font-bold tracking-tight">
|
||||||
{fullName(rider) || 'Ohne Name'}
|
{riderName(rider) || 'Ohne Name'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -131,7 +131,7 @@
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="pb-2">
|
<CardHeader class="pb-2">
|
||||||
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Runs</CardTitle>
|
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Stages</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
|
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
|
||||||
|
|
@ -163,19 +163,19 @@
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
<Timer class="h-4 w-4 text-primary" />
|
<Timer class="h-4 w-4 text-primary" />
|
||||||
Runs ({rows.length})
|
Stages ({rows.length})
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
{#if rows.length === 0}
|
{#if rows.length === 0}
|
||||||
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
|
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
|
||||||
Für dieses Event gibt es noch keine Runs.
|
Für dieses Event gibt es noch keine Stages.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Run</Table.Head>
|
<Table.Head>Stage</Table.Head>
|
||||||
<Table.Head class="w-24">Status</Table.Head>
|
<Table.Head class="w-24">Status</Table.Head>
|
||||||
<Table.Head class="text-right w-28">Zeit</Table.Head>
|
<Table.Head class="text-right w-28">Zeit</Table.Head>
|
||||||
<Table.Head class="text-right w-20">Platz</Table.Head>
|
<Table.Head class="text-right w-20">Platz</Table.Head>
|
||||||
|
|
@ -183,17 +183,17 @@
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each rows as row (row.run.id)}
|
{#each rows as row (row.stage.id)}
|
||||||
<Table.Row
|
<Table.Row
|
||||||
class="cursor-pointer"
|
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">
|
<Table.Cell class="font-medium">
|
||||||
<a
|
<a
|
||||||
href="/dashboard/events/{event.id}/runs/{row.run.id}"
|
href="/dashboard/events/{event.id}/stages/{row.stage.id}"
|
||||||
class="hover:underline"
|
class="hover:underline"
|
||||||
>
|
>
|
||||||
{row.run.description || 'Run'}
|
{row.stage.description || 'Stage'}
|
||||||
</a>
|
</a>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
|
@ -244,5 +244,5 @@
|
||||||
bind:open={timeDialog}
|
bind:open={timeDialog}
|
||||||
eventId={event?.id ?? ''}
|
eventId={event?.id ?? ''}
|
||||||
riderId={rider?.id ?? null}
|
riderId={rider?.id ?? null}
|
||||||
runId={dialogRun}
|
stageId={dialogStage}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<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
|
* laufenden zählen in Echtzeit mit. Die Tabelle zeigt nur an — gestartet
|
||||||
* und gestoppt wird ausschließlich im Zeit-Dialog. Auf dem Handy an der
|
* und gestoppt wird ausschließlich im Zeit-Dialog. Auf dem Handy an der
|
||||||
* Strecke ist ein versehentlicher Treffer sonst schnell passiert, und
|
* Strecke ist ein versehentlicher Treffer sonst schnell passiert, und
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getStageContext } from '$lib/stores/stages.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
|
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
|
|
@ -30,17 +30,17 @@
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||||
const 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.
|
// 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.
|
// Am Start steht, wer beim Event zugesagt hat.
|
||||||
const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
|
const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
|
||||||
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
|
const running = $derived(valid && stage ? times.runningByStage(stage.id, clock.now) : [])
|
||||||
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
|
const leaderboard = $derived(valid && stage ? times.getLeaderboard(stage.id) : [])
|
||||||
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
|
const unranked = $derived(valid && stage ? times.unrankedByStage(stage.id) : [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
|
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Zeitnahme --------------------------------------------------------
|
// --- Zeitnahme --------------------------------------------------------
|
||||||
// Start und Stopp laufen ausschließlich über TimeDialog; der Run ist
|
// Start und Stopp laufen ausschließlich über TimeDialog; die Stage ist
|
||||||
// dabei vorbelegt, den Fahrer wählt man dort.
|
// dabei vorbelegt, den Fahrer wählt man dort.
|
||||||
let timeDialog = $state(false)
|
let timeDialog = $state(false)
|
||||||
|
|
||||||
|
|
@ -75,14 +75,14 @@
|
||||||
rows.push([
|
rows.push([
|
||||||
String(i + 1),
|
String(i + 1),
|
||||||
event ? participants.numberOf(event.id, t.rider) : '',
|
event ? participants.numberOf(event.id, t.rider) : '',
|
||||||
r ? fullName(r) : '',
|
r ? riderName(r) : '',
|
||||||
t.formattedTime ?? '',
|
t.formattedTime ?? '',
|
||||||
String(t.correction ?? 0),
|
String(t.correction ?? 0),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
|
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
|
||||||
const safeName = (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 blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
|
|
@ -93,13 +93,13 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{run?.description ?? 'Run'} – {event?.name ?? 'Event'}</title></svelte:head>
|
<svelte:head><title>{stage?.description ?? 'Stage'} – {event?.name ?? 'Event'}</title></svelte:head>
|
||||||
|
|
||||||
{#if !valid}
|
{#if !valid}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-16 text-center">
|
<CardContent class="py-16 text-center">
|
||||||
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<p class="text-lg font-medium">Run nicht gefunden</p>
|
<p class="text-lg font-medium">Stage nicht gefunden</p>
|
||||||
<p class="text-sm text-muted-foreground mt-1">
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
|
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{:else if event && run}
|
{:else if event && stage}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
<ArrowLeft class="h-3 w-3" />
|
<ArrowLeft class="h-3 w-3" />
|
||||||
{event.name || 'Event'}
|
{event.name || 'Event'}
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
|
<h1 class="text-3xl font-bold tracking-tight">{stage.description || 'Stage'}</h1>
|
||||||
<p class="mt-1 text-muted-foreground">
|
<p class="mt-1 text-muted-foreground">
|
||||||
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start
|
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -195,7 +195,7 @@
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
{#if r}
|
{#if r}
|
||||||
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
|
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a>
|
||||||
{:else}
|
{:else}
|
||||||
—
|
—
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -233,7 +233,7 @@
|
||||||
</Badge>
|
</Badge>
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{#if r}
|
{#if r}
|
||||||
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
|
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a>
|
||||||
{:else}
|
{:else}
|
||||||
—
|
—
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -249,4 +249,4 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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.
|
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
|
||||||
*
|
*
|
||||||
* „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die
|
* Hier steht nur noch, was einen selbst betrifft — das eigene Profil und
|
||||||
* Menschen: Jedes Mitglied ist ein Fahrer, ein Konto hat aber nicht jeder.
|
* die Flag-Typen. Team und Kader sind unter „Team" zu Hause; sie sind
|
||||||
* Beides an einer Stelle, weil es sonst zwei Wahrheiten über dieselbe
|
* keine Einstellung, sondern der Gegenstand der Anwendung.
|
||||||
* Person gäbe. Der Reiter erscheint nur für die Teamleitung.
|
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { Flag, User } from 'lucide-svelte'
|
||||||
import { Flag, User, Users, Bike } from 'lucide-svelte'
|
|
||||||
|
|
||||||
let { children } = $props()
|
let { children } = $props()
|
||||||
|
|
||||||
const teams = getTeamContext()
|
const tabs = [
|
||||||
|
|
||||||
const tabs = $derived([
|
|
||||||
{ href: '/dashboard/settings', label: 'Profil', icon: User },
|
{ href: '/dashboard/settings', label: 'Profil', icon: User },
|
||||||
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
|
|
||||||
...(teams.isAdmin()
|
|
||||||
? [{ href: '/dashboard/settings/riders', label: 'Fahrer & Konten', icon: Bike }]
|
|
||||||
: []),
|
|
||||||
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
|
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
|
||||||
])
|
]
|
||||||
|
|
||||||
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
|
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
|
||||||
// auf jedem Unterpfad mit aktiv.
|
// auf jedem Unterpfad mit aktiv.
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,40 @@
|
||||||
* also niemand für jemand anderen ändern.
|
* also niemand für jemand anderen ändern.
|
||||||
*/
|
*/
|
||||||
import { api, auth } from '$lib/stores/pocketbase.svelte'
|
import { api, auth } from '$lib/stores/pocketbase.svelte'
|
||||||
|
import { app } from '$lib/stores/app.svelte'
|
||||||
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import Avatar from '$lib/components/Avatar.svelte'
|
import Avatar from '$lib/components/Avatar.svelte'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { KeyRound, User } from 'lucide-svelte'
|
import { Camera, KeyRound, Trash2, User } from 'lucide-svelte'
|
||||||
|
|
||||||
let name = $state(auth.user?.name ?? '')
|
let name = $state(auth.user?.name ?? '')
|
||||||
let avatarFile = $state<File | null>(null)
|
let avatarFile = $state<File | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschau des gewaehlten Bildes. Ohne sie bliebe die Wahl unsichtbar —
|
||||||
|
* das Dateifeld ist versteckt, es gibt also keinen Dateinamen zu lesen.
|
||||||
|
* Die Object-URL wird beim naechsten Wechsel wieder freigegeben, sonst
|
||||||
|
* haelt der Browser jedes einmal angesehene Bild bis zum Seitenwechsel
|
||||||
|
* fest.
|
||||||
|
*/
|
||||||
|
let avatarPreview = $state<string | null>(null)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const file = avatarFile
|
||||||
|
if (!file) {
|
||||||
|
avatarPreview = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
avatarPreview = url
|
||||||
|
return () => URL.revokeObjectURL(url)
|
||||||
|
})
|
||||||
let savingProfile = $state(false)
|
let savingProfile = $state(false)
|
||||||
let profileMessage = $state<string | null>(null)
|
let profileMessage = $state<string | null>(null)
|
||||||
let profileError = $state<string | null>(null)
|
let profileError = $state<string | null>(null)
|
||||||
|
|
@ -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 oldPassword = $state('')
|
||||||
let newPassword = $state('')
|
let newPassword = $state('')
|
||||||
let confirmPassword = $state('')
|
let confirmPassword = $state('')
|
||||||
|
|
@ -109,36 +183,53 @@
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="flex items-center gap-4">
|
<!--
|
||||||
<Avatar user={auth.user} size={64} />
|
Das Bild steht neben Name und Adresse und reicht ueber beide
|
||||||
<div class="space-y-1">
|
Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt
|
||||||
<Label for="avatar">Bild</Label>
|
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
|
<input
|
||||||
id="avatar"
|
id="avatar"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="block w-full text-sm"
|
class="sr-only"
|
||||||
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
|
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">
|
</label>
|
||||||
Ohne Bild stehen deine Initialen da.
|
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="profile-name">Name</Label>
|
|
||||||
<Input id="profile-name" bind:value={name} placeholder="Wie du angezeigt wirst" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="profile-mail">E-Mail</Label>
|
|
||||||
<Input id="profile-mail" value={auth.user?.email ?? ''} readonly disabled />
|
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
|
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
|
||||||
Team-Administration über PocketBase.
|
Team-Administration über PocketBase.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if profileError}
|
{#if profileError}
|
||||||
<p class="text-sm text-destructive">{profileError}</p>
|
<p class="text-sm text-destructive">{profileError}</p>
|
||||||
|
|
@ -187,4 +278,43 @@
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Eine eigene Karte, ganz unten: Was nicht rückgängig zu machen ist,
|
||||||
|
steht nicht zwischen Namensfeld und Passwortwechsel.
|
||||||
|
-->
|
||||||
|
<Card class="lg:col-span-2 border-destructive/30">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<Trash2 class="size-4 text-destructive" />
|
||||||
|
Konto löschen
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
{#if ownedTeams.length > 0}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Du bist Owner von
|
||||||
|
{ownedTeams.map((t) => `„${t.name}"`).join(', ')}. Ein Team braucht
|
||||||
|
seinen Owner: Übergib es oder lösche es zuerst, danach lässt sich
|
||||||
|
dein Konto löschen.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" disabled>Konto löschen</Button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Dein Konto wird endgültig gelöscht und du wirst abgemeldet. Dein
|
||||||
|
Fahrer bleibt mit allen Zeiten und Teilnahmen im Kader stehen — nur
|
||||||
|
anmelden kannst du dich nicht mehr.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if deleteError}
|
||||||
|
<p class="text-sm text-destructive">{deleteError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button variant="outline" onclick={deleteAccount} disabled={deletingAccount}>
|
||||||
|
<Trash2 class="size-4 mr-2 text-destructive" />
|
||||||
|
{deletingAccount ? 'Wird gelöscht …' : 'Konto löschen'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</div>
|
||||||
{/if}
|
{/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">
|
<span class="flex items-center gap-1.5">
|
||||||
<Ruler class="size-4" />
|
<Ruler class="size-4" />
|
||||||
{formatKm(version?.distance_m)}
|
{formatKm(version?.distance_m)}
|
||||||
|
|
@ -158,27 +165,9 @@
|
||||||
<TrendingUp class="size-4" />
|
<TrendingUp class="size-4" />
|
||||||
{version?.ascent_m ? `${version.ascent_m} hm` : '–'}
|
{version?.ascent_m ? `${version.ascent_m} hm` : '–'}
|
||||||
</span>
|
</span>
|
||||||
{#if open > 0}
|
|
||||||
<span
|
|
||||||
class="flex items-center gap-1 text-amber-600 ml-auto"
|
|
||||||
use:tooltip={{
|
|
||||||
content: `${open} offene Meldung${open === 1 ? '' : 'en'}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertTriangle class="size-4" />
|
|
||||||
{open}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Ein Punkt markiert Bewegung der letzten zwei Wochen. Die
|
|
||||||
Zahl allein sagt nichts darüber, ob sich gerade etwas tut.
|
|
||||||
-->
|
|
||||||
<div class="flex items-center gap-4 text-sm border-t pt-3">
|
|
||||||
<span
|
<span
|
||||||
class="flex items-center gap-1.5"
|
class="flex items-center gap-1.5"
|
||||||
class:text-muted-foreground={!freshMarkers}
|
class:text-foreground={freshMarkers}
|
||||||
class:font-medium={freshMarkers}
|
class:font-medium={freshMarkers}
|
||||||
use:tooltip={{
|
use:tooltip={{
|
||||||
content: freshMarkers
|
content: freshMarkers
|
||||||
|
|
@ -194,7 +183,7 @@
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="flex items-center gap-1.5"
|
class="flex items-center gap-1.5"
|
||||||
class:text-muted-foreground={!freshComments}
|
class:text-foreground={freshComments}
|
||||||
class:font-medium={freshComments}
|
class:font-medium={freshComments}
|
||||||
use:tooltip={{
|
use:tooltip={{
|
||||||
content: freshComments
|
content: freshComments
|
||||||
|
|
@ -208,6 +197,17 @@
|
||||||
<span class="size-1.5 rounded-full bg-primary"></span>
|
<span class="size-1.5 rounded-full bg-primary"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
{#if open > 0}
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 text-amber-600 ml-auto"
|
||||||
|
use:tooltip={{
|
||||||
|
content: `${open} offene Meldung${open === 1 ? '' : 'en'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle class="size-4" />
|
||||||
|
{open}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
import IconPicker from '$lib/components/IconPicker.svelte'
|
import IconPicker from '$lib/components/IconPicker.svelte'
|
||||||
import Avatar from '$lib/components/Avatar.svelte'
|
import Avatar from '$lib/components/Avatar.svelte'
|
||||||
import { tooltip } from '$lib/stores/app.svelte'
|
import { tooltip } from '$lib/stores/app.svelte'
|
||||||
|
import { formatDateTime, formatRelative } from '$lib/time'
|
||||||
import { positionAtDistance } from '$lib/gpx'
|
import { positionAtDistance } from '$lib/gpx'
|
||||||
import { app } from '$lib/stores/app.svelte'
|
import { app } from '$lib/stores/app.svelte'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -276,9 +277,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Hilfsfunktionen -------------------------------------------------
|
// --- Hilfsfunktionen -------------------------------------------------
|
||||||
function formatDate(s: string) {
|
// Dieselbe Formatierung stand hier als eigene Zeile Code und in
|
||||||
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
// $lib/time; jetzt kommt sie nur noch von dort.
|
||||||
}
|
const formatDate = formatDateTime
|
||||||
|
|
||||||
async function setStatus(status: string) {
|
async function setStatus(status: string) {
|
||||||
if (!trail || status === trail.status) return
|
if (!trail || status === trail.status) return
|
||||||
|
|
@ -368,9 +369,17 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if trail.status_changed}
|
{#if trail.status_changed}
|
||||||
|
<!--
|
||||||
|
Relativ, weil am Zustand die Frage „wie alt ist die
|
||||||
|
Meldung?" haengt und nicht „welcher Tag war das?". Das
|
||||||
|
genaue Datum steht im Tooltip.
|
||||||
|
-->
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
|
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
|
||||||
gemeldet am {formatDate(trail.status_changed)}
|
gemeldet
|
||||||
|
<span use:tooltip={{ content: formatDateTime(trail.status_changed) }}>
|
||||||
|
{formatRelative(trail.status_changed)}
|
||||||
|
</span>
|
||||||
{#if trail.status_by}
|
{#if trail.status_by}
|
||||||
von {teams.userLabel(trail.status_by)}
|
von {teams.userLabel(trail.status_by)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
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 { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { LoaderCircle } from 'lucide-svelte'
|
import Spinner from '$lib/components/Spinner.svelte'
|
||||||
import Logo from '$lib/components/Logo.svelte'
|
import Logo from '$lib/components/Logo.svelte'
|
||||||
|
|
||||||
let email = $state('')
|
let email = $state('')
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
|
|
||||||
<Button type="submit" class="w-full" disabled={submitting}>
|
<Button type="submit" class="w-full" disabled={submitting}>
|
||||||
{#if submitting}
|
{#if submitting}
|
||||||
<LoaderCircle class="h-4 w-4 mr-2 animate-spin" />
|
<Spinner class="mr-2 size-4" />
|
||||||
Wird angemeldet…
|
Wird angemeldet…
|
||||||
{:else}
|
{:else}
|
||||||
Anmelden
|
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