Compare commits

..

15 commits

Author SHA1 Message Date
Daniel Michelberger
0f4b532773 fix: kein Fokusring am Menuepunkt Team nach dem Zuklappen
bits-ui gibt den Fokus beim Schliessen an den Trigger zurueck. Bei einem
Menue, das per Klick aufgeht, ist das richtig — hier schliesst der
Zeiger, und zurueck blieb ein Fokusring auf einem Menuepunkt, ueber den
jemand nur gefahren ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:18:48 +02:00
Daniel Michelberger
893509be9d refactor: Aus Runs werden Stages, bis in die Datenbank
"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.

Die Migration benennt um statt neu anzulegen: Collection und Feld
behalten ihre IDs, PocketBase benennt Tabelle und Spalte um, die Daten
bleiben stehen. Gesucht wird ueber die Collection-ID und nicht ueber den
Namen, damit sie auch auf einer frischen Datenbank durchlaeuft, deren
Snapshot die Collection bereits `stages` nennt.

Im Frontend wandert `times.run` zu `times.stage`, der Store heisst
stages.svelte.ts, und aus /events/[id]/runs/[runId] wird
/events/[id]/stages/[stageId].

Nicht umbenannt: `running`, `allRunning` und RunningTimesToast. Das sind
laufende Zeiten und keine Stages — dieselben Buchstaben, andere Sache.

Und die Artikel: Der Run war maskulin, die Stage ist feminin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:16:22 +02:00
Daniel Michelberger
3120bbfdbf fix: Fahrer fuer Teammitglieder, die keinen mehr haben
Bis zuletzt loeschte die Anwendung einen Fahrer, ohne sein Konto aus
teams.users zu nehmen. Zurueck blieb ein Konto ohne Fahrer — im Kader
eine Zeile, aus der kein Weg zurueckfuehrte: Ein bestehendes Konto
nachtraeglich an einen Fahrer zu haengen gibt es bewusst nicht, weil
dieselbe Person sonst zweimal in der Liste stehen koennte.

riders.remove nimmt die Mitgliedschaft inzwischen mit; diese Migration
raeumt auf, was vorher liegengeblieben ist, und stellt die Regel her,
auf die sich die Oberflaeche verlassen darf: Wer im Team ist, steht im
Kader.

Kein Rueckweg: Die hier angelegten Fahrer sind hinterher nicht mehr von
denen zu unterscheiden, die die Teamleitung selbst angelegt hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:11:44 +02:00
Daniel Michelberger
041bed923b feat: Profilbild als Knopf, und das eigene Konto laesst sich loeschen
Das Bild steht links ueber Name und Adresse und ist zugleich der Knopf:
Das Dateifeld liegt unsichtbar darunter, angeklickt wird das Bild. Ein
Label "Bild" ueber einem Dateifeld sagte nichts, was das Bild nicht
schon zeigt. Dazu eine Vorschau der Wahl — ohne sie bliebe sie
unsichtbar, weil es keinen Dateinamen mehr zu lesen gibt.

Loeschen des eigenen Kontos erlaubt users.deleteRule laengst
(id = @request.auth.id); es fehlte nur der Weg dorthin. Die Fahrer
bleiben stehen: riders.user haengt ohne Kaskade daran, also behaelt das
Team Namen, Zeiten und Teilnahmen und die Person verliert nur ihr Login.

Wer ein Team besitzt, kommt nicht durch. teams.owner ist ein Pflichtfeld
ohne Kaskade; PocketBase muesste es beim Loeschen leeren und braeche
dabei den Datensatz. Der Versuch endete in einem Fehler, den niemand
deuten kann — deshalb steht die Bedingung vorne und im Klartext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:11:44 +02:00
Daniel Michelberger
b3d77dddbe fix: doppeltes Padding in Karten mit Knopfzeile
Die Karte bringt py-4 mit; Inhalt und Knopfzeile brachten es ein zweites
Mal. Unter dem letzten Knopf standen dadurch 32 statt 16 px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:11:28 +02:00
Daniel Michelberger
66e351e54f feat: Team wird zum Menuepunkt mit Submenue
Das Teamdropdown stand als eigenes Bedienelement neben dem Namen — ein
zweiter Schalter fuer eine Sache, die schon einen Menuepunkt hat. Jetzt
klappt der Menuepunkt Team beim Ueberfahren auf: die eigenen Teams, ein
Klick wechselt und oeffnet die Teamseite, darunter "Team erstellen".

Die Zeiger- und Tastenhandler von bits-ui werden am Trigger bewusst
ueberschrieben. Deren onclick ruft preventDefault und macht den Link
tot, ihr pointerdown klappt zu, was der Hover gerade aufgeklappt hat —
zwei Herren ueber dasselbe open, das Ergebnis war Flackern statt
Navigation. Das Menue verzichtet zudem auf Fokusfang und Scrollsperre;
beides gehoert zu einem Dialog, nicht zu etwas, ueber das der Zeiger nur
streift.

Der Anlegedialog liegt jetzt als CreateTeamDialog vor: Es gibt zwei
Einstiege, und ein neues Team muss an beiden dasselbe bedeuten — Team
anlegen, Standardsatz Flag-Typen dazu, ab dann ist es das aktive.

Das Logo ragt mit -my-3 leicht ueber die Kopfleiste hinaus, verlinkt
aufs Dashboard und teilt sich den Hover mit dessen Menuepunkt. Der
aktive Menuepunkt traegt das Primary-Design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:11:28 +02:00
Daniel Michelberger
45835cd45c refactor: Team und Kader an einem Ort
Frueher lag beides in den Einstellungen und ein blasses Verzeichnis
zusaetzlich unter "Fahrer". Wer einen Fahrer anlegen wollte, landete
erst im Verzeichnis, dann in den Einstellungen. Jetzt gibt es einen Ort,
und die Verwaltungsknoepfe stehen an den Zeilen, zu denen sie gehoeren.

Der Einladungslink sitzt im Kopf der Seite statt in einer eigenen
Section; die ausgegebenen Links stehen mit im Dialog, weil sie sonst mit
der Section auch das Kopieren und das Zuruecknehmen verloren haetten.

Die Adminrolle wandert aus der Tabellenzeile in den Personendialog. Ein
Knopf, der ohne Rueckfrage Rechte vergibt, gehoert nicht neben die Icons
fuers Entfernen und Loeschen. Der Bearbeiten-Knopf steht dafuer an jeder
Zeile, auch an Konten ohne Fahrer — sonst waere deren Rolle nirgends
mehr erreichbar.

Die Liste "Meine Teams" entfaellt: Wechseln und Anlegen stehen im
Submenue am Menuepunkt Team. Verlassen gilt nur dem aktiven Team und
steht deshalb jetzt im Kopf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:11:13 +02:00
Daniel Michelberger
8c11095b17 feat: Ein Fahrer hat einen Namen, nicht zwei
Vorname und Nachname getrennt zu fuehren versprach eine Ordnung, die es
hier nie gab: sortiert wird ueber den ganzen Namen, angezeigt wird der
ganze Name, gesucht wird ueber beides zugleich. Dafuer musste jede Maske
zwei Felder anbieten und jede Anzeige sie wieder zusammensetzen — und
wer schlicht "Schorsch" heisst, stand vor der Frage, welches der beiden
Felder das ist.

Das Feld name gibt es an riders laengst; es lag nur brach, weil die
Anwendung firstname und lastname benutzte. Es uebernimmt deren Inhalt —
kein neues Feld, keine zweite Spalte, nichts, was auf bestehenden
Instanzen erst entstehen muesste.

Zurueck geht es nur ungenau: Die Umkehrung trennt am ersten Leerzeichen
und raet damit bei jedem Doppelvornamen falsch. Das ist der Preis dafuer,
dass die Trennung ueberhaupt verschwindet.

fullName heisst jetzt riderName und ist nur noch ein Feldzugriff — die
Funktion bleibt als die eine Stelle, an der ein namenloser Fahrer zu
einem leeren String wird statt als undefined durch die Oberflaeche zu
geistern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:10:30 +02:00
Daniel Michelberger
eac2455ee3 chore(schema): team_invites in den Snapshot nachziehen
Damit eine frische Instanz die Collection direkt aus dem Snapshot
bekommt und die Typen aus derselben versionierten Quelle entstehen wie
das Schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:10:08 +02:00
Daniel Michelberger
bb0a51378f refactor: Marker und Kommentare in die Kennzahlenzeile der Trailcard
Sie standen in einer eigenen, abgetrennten Zeile unter Laenge und
Hoehenmetern, obwohl sie dasselbe sind: eine Zahl zum Trail. Jetzt eine
Zeile, ganz rechts die offenen Meldungen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:09:41 +02:00
Daniel Michelberger
3ba8ec4c86 feat: Trailstatus in relativer Zeit
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 steht daneben im Tooltip und geht
dadurch nicht verloren.

date-fns lag bereits im Projekt, es brauchte keine neue Library.
parseDate nimmt PocketBases "2026-09-12 08:30:00.000Z" mit Leerzeichen
statt T; die meisten Browser schlucken das, garantiert ist es nicht.

formatDate auf der Trail-Seite war eine Kopie von formatDateTime und
kommt jetzt aus $lib/time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:09:41 +02:00
Daniel Michelberger
70c2cdd101 fix: Zeigefinger fuer alles Anklickbare
Tailwind 4 nimmt Knoepfen den Zeigefinger und ueberlaesst sie dem
Browser-Standard — was am Ende ein Pfeil ist, der nichts verspricht.
Eine Basisregel statt einer Klasse an jedem einzelnen Element.
Abgeschaltete Elemente behalten den Pfeil.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:09:30 +02:00
Daniel Michelberger
c817e208cd fix: Kartenkacheln von OpenFreeMap statt CARTO
CARTO liefert die Kacheln inzwischen mit einem eingebrannten
"API KEY REQUIRED" quer ueber der Karte — und zwar mit HTTP 200,
weshalb es wie ein Darstellungsfehler aussah und nicht wie eine Sperre.
Der Schluessel aus PUBLIC_CARTO_API_KEY aenderte daran nichts: Die
Kachel kam byteidentisch zurueck wie ohne ihn, als key=, api_key= und
apikey=.

OpenFreeMap braucht keinen Schluessel und kennt kein Limit; die Stile
heissen dort positron und dark und sind genau die, die CARTO als
Positron und Dark Matter gepraegt hat.

Weil das Vektorkacheln sind, tauscht der Themenwechsel jetzt den ganzen
Stil statt nur der Kachel-URLs. Ein Stilwechsel wirft Quellen und Layer
weg, deshalb legt style.load die Trail-Linie neu an — die Marker nicht,
die haengen als DOM-Elemente an der Karte und nicht am Stil.

PUBLIC_CARTO_API_KEY wird nicht mehr gelesen und kann aus .env und der
Deployment-Konfiguration verschwinden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:09:30 +02:00
Daniel Michelberger
69588528e8 feat: Ladeanzeige als Mountainbike-Laufrad
Stollenreifen, Felge, gekreuzte Speichen, Nabe — gezeichnet in
currentColor und groessengesteuert wie ein Lucide-Icon, damit es den
LoaderCircle ueberall ersetzen kann.

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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:09:30 +02:00
Daniel Michelberger
7ac5e9ab4f feat: Einladungslinks, mit denen sich Fahrer selbst ein Konto anlegen
Bisher legte die Teamleitung jedem Fahrer von Hand ein Konto an und gab
das Passwort weiter. Bei zwanzig Leuten in einer WhatsApp-Gruppe ist ein
Link die kuerzere Strecke.

Die oeffentliche Seite der Einladung laeuft ueber eigene Routen in
pb_hooks, nicht ueber die Collection: Ohne Login ist dort nichts
sichtbar, auch nicht mit Token. Der Token kommt vom Server, ein
mitgeschickter wird abgewiesen.

Dazu der Login-Vorspann vor jeder Regel, die @request.auth.id erwaehnt.
Das ist ein Loch und keine Kosmetik: Eine leere Relation ist in
PocketBase gleich dem leeren @request.auth.id einer anonymen Anfrage —
ein Team ohne Admins stuende sonst offen im Netz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:09:14 +02:00
48 changed files with 3446 additions and 1756 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
stammtisch-hersbruck.de is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, 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
View 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
View file

@ -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.

View file

@ -21,7 +21,7 @@ Damit das Frontend gegen diese Instanz läuft, in `../frontend/.env`
Das Schema liegt als versionierte Migration in `pb_migrations/` und wird beim Das Schema liegt als versionierte Migration in `pb_migrations/` und wird beim
Start automatisch angewendet. Es enthält die sechs fachlichen Collections Start automatisch angewendet. Es enthält die sechs fachlichen Collections
`users`, `teams`, `events`, `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,

View 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())

View 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 }

View file

@ -588,7 +588,7 @@ migrate((app) => {
"createRule": "team.users.id ?= @request.auth.id", "createRule": "team.users.id ?= @request.auth.id",
"updateRule": "team.users.id ?= @request.auth.id", "updateRule": "team.users.id ?= @request.auth.id",
"deleteRule": "team.users.id ?= @request.auth.id", "deleteRule": "team.users.id ?= @request.auth.id",
"name": "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
} }
] ]

View 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'))
})

View 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)
}
})

View 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)
}
})

View file

@ -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.
})

View 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)
}
})

View file

@ -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;
}
} }

View 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>

View file

@ -18,11 +18,11 @@
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null) const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
</script> </script>
<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>

View file

@ -3,17 +3,17 @@
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange * Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen * irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr * zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die 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}

View 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>

View 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} />

View file

@ -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>

View file

@ -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>

View file

@ -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 = '&copy; OpenStreetMap-Mitwirkende, &copy; 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({
id: 'trail-line',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 4 },
})
map.addLayer({
id: 'trail-hit',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 },
})
bindTrailEvents(map)
} }
})
/** Beim Themenwechsel die Kachelquelle austauschen. */ instance.addSource('trail', { type: 'geojson', data: feature })
instance.addLayer({
id: 'trail-line',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 4 },
})
instance.addLayer({
id: 'trail-hit',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 },
})
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

View file

@ -1,7 +1,7 @@
import { getContext, setContext } from 'svelte' import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte' import { api, auth } from './pocketbase.svelte'
import { getEventContext } from './events.svelte' import { getEventContext } from './events.svelte'
import { getRiderContext } 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() {

View file

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

View file

@ -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() {

View file

@ -2,19 +2,19 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte' import { api } from './pocketbase.svelte'
import { app } from './app.svelte' import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte' import { getTeamContext } from './teams.svelte'
import type { 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)
} }

View 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)
}

View file

@ -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()

View file

@ -75,11 +75,11 @@ export class TimeStore {
}).catch((e) => console.warn('times subscribe failed:', e)) }).catch((e) => console.warn('times subscribe failed:', e))
} }
async start(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
} }

View file

@ -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). */

View file

@ -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>

View file

@ -2,7 +2,7 @@
import {auth} from '$lib/stores/pocketbase.svelte' import {auth} from '$lib/stores/pocketbase.svelte'
import {goto} from '$app/navigation' import {goto} from '$app/navigation'
import {onMount} from 'svelte' import {onMount} from 'svelte'
import {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>

View file

@ -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?', await auth.logout()
yes: async () => { goto('/login')
await auth.logout()
app.confirm.close()
goto('/login')
},
no: () => app.confirm.close(),
})
} }
// Ein Menuepunkt sieht aus wie der andere, ob Link oder aufklappbar.
const navItemClass =
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors'
const navIdleClass = 'text-muted-foreground hover:text-foreground hover:bg-accent'
const navHoverClass = 'text-foreground bg-accent'
const navActiveClass = 'bg-primary text-primary-foreground hover:bg-primary/90'
/**
* Wo man gerade steht. „/dashboard" nur genau dort — sonst waere das
* Dashboard auf jeder Unterseite mit-aktiv, weil alle Pfade damit
* anfangen.
*/
function isActive(href: string) {
const path = page.url.pathname
return href === '/dashboard' ? path === '/dashboard' : path.startsWith(href)
}
/**
* Die vollstaendige Klassenliste eines Menuepunkts. Bewusst eine
* Funktion und kein zusaetzliches `class:`: `text-foreground` und
* `text-muted-foreground` sind dieselbe Utility-Gruppe — stuenden beide
* am Element, entschiede die Reihenfolge im Stylesheet und nicht die im
* Attribut.
*/
function navClass(href: string, hovered = false) {
if (isActive(href)) return `${navItemClass} ${navActiveClass}`
return `${navItemClass} ${hovered ? navHoverClass : navIdleClass}`
}
// Logo und Menuepunkt „Dashboard" fuehren an dieselbe Stelle, also
// reagieren sie gemeinsam — egal, welches von beiden man ueberfaehrt.
// Ueber CSS ginge das nicht: Der Menuepunkt steckt in der <nav>, das Logo
// daneben, und peer-hover reicht nur an Geschwister, nicht in sie hinein.
let dashHover = $state(false)
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides // 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,78 +156,89 @@
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}
<a {#if item.submenu}
href={item.href} <TeamMenu class={navClass(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" {:else}
> <a
<Icon class="h-4 w-4" /> href={item.href}
<span>{item.label}</span> class={navClass(item.href, dashHover && item.href === '/dashboard')}
</a> onmouseenter={() => item.href === '/dashboard' && (dashHover = true)}
onmouseleave={() => item.href === '/dashboard' && (dashHover = false)}
>
<Icon class="h-4 w-4" />
<span>{item.label}</span>
</a>
{/if}
{/each} {/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"> <a
<div class="flex items-center justify-between gap-3"> href="/dashboard/settings"
<a class="flex min-w-0 items-center gap-2"
href="/dashboard/settings" use:tooltip={{ content: 'Profil' }}
class="flex min-w-0 items-center gap-2" >
use:tooltip={{ content: 'Profil' }} <Avatar user={auth.user} size={24} />
> <span class="truncate text-sm font-medium leading-none">
<Avatar user={auth.user} size={24} /> {auth.user.name || auth.user.email}
<span class="truncate text-sm font-medium leading-none">
{auth.user.name || auth.user.email}
</span>
</a>
<span class="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={toggleMode}
tooltip="Design wechseln"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={handleLogout}
tooltip="Abmelden"
>
<LogOut class="h-4 w-4" />
</Button>
</span> </span>
</div> </a>
<div class="flex"> <span class="flex shrink-0 items-center gap-1">
<TeamSwitcher /> <Button
</div> variant="ghost"
size="icon"
class="size-7"
onclick={toggleMode}
tooltip="Design wechseln"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={handleLogout}
tooltip="Abmelden"
>
<LogOut class="h-4 w-4" />
</Button>
</span>
</div> </div>
<Button <Button
@ -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}
<a {#if item.submenu}
href={item.href} <TeamMenu
onclick={() => (isMobileMenuOpen = false)} class="w-full {navClass(item.href)}"
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent" onnavigate={() => (isMobileMenuOpen = false)}
> />
<Icon class="h-4 w-4" /> {:else}
<span>{item.label}</span> <a
</a> href={item.href}
onclick={() => (isMobileMenuOpen = false)}
class="w-full {navClass(item.href)}"
>
<Icon class="h-4 w-4" />
<span>{item.label}</span>
</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">

View file

@ -4,7 +4,7 @@
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { getRiderContext } from '$lib/stores/riders.svelte' import { getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { 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>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { 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>

View file

@ -2,8 +2,8 @@
import { page } from '$app/state' import { page } from '$app/state'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { 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>

View file

@ -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}
/> />

View file

@ -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} />

View file

@ -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>

View file

@ -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.

View file

@ -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"> <p class="text-xs text-muted-foreground">
<Label for="profile-name">Name</Label> Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
<Input id="profile-name" bind:value={name} placeholder="Wie du angezeigt wirst" /> Team-Administration über PocketBase.
</div> </p>
<div class="space-y-2">
<Label for="profile-mail">E-Mail</Label>
<Input id="profile-mail" value={auth.user?.email ?? ''} readonly disabled />
<p class="text-xs text-muted-foreground">
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
Team-Administration über PocketBase.
</p>
</div>
{#if profileError} {#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>

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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}

View 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>

View file

@ -5,7 +5,7 @@
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB