Compare commits
13 commits
0432540d5a
...
27d34ad0d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27d34ad0d3 | ||
|
|
561d7cbbc7 | ||
|
|
27c83946df | ||
|
|
fe8e301e65 | ||
|
|
6b1c57ba17 | ||
|
|
f8247ae3e7 | ||
|
|
7c3cf29354 | ||
|
|
1cd708e0ff | ||
|
|
cc55bd66a5 | ||
|
|
514b5de27b | ||
|
|
245f8d7924 | ||
|
|
a8049e7406 | ||
|
|
f29361b7f3 |
47 changed files with 4459 additions and 1401 deletions
100
TODO.md
Normal file
100
TODO.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Offene Punkte
|
||||
|
||||
Stand: 2026-09-02. Was hier steht, ist bewusst nicht erledigt — entweder weil
|
||||
es eine Entscheidung braucht oder weil es außerhalb des Repos passiert.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
### Trail-Status: dürfen Mitglieder melden?
|
||||
|
||||
Anzeige und Herkunft (`status_changed`, `status_by`) sind fertig. Ändern darf
|
||||
den Status weiterhin nur, wer Pate oder Team-Admin ist — `trails.updateRule`
|
||||
lässt nichts anderes zu.
|
||||
|
||||
Sollen alle Teammitglieder melden dürfen, braucht es eine Regel, die genau ein
|
||||
Feld freigibt: `team.users.id ?= @request.auth.id` kombiniert mit
|
||||
`@request.body.<feld>:isset = false` für **jedes** andere Feld. Das
|
||||
funktioniert, wird aber lang und bricht still, sobald ein Feld dazukommt.
|
||||
Deshalb erst nach ausdrücklicher Entscheidung.
|
||||
|
||||
### Fahrer: Kader oder Event-Teilnehmer?
|
||||
|
||||
`riders.event` bindet einen Fahrer an genau ein Event. Ein in den Einstellungen
|
||||
angelegter Fahrer ohne Event steht deshalb nur im Verzeichnis und taucht in
|
||||
keiner Zeitnahme auf; der Dialog hat dafür ein optionales Event-Feld.
|
||||
|
||||
Ein echter Team-Kader, aus dem man Fahrer in Events übernimmt, wäre eine
|
||||
n:m-Beziehung — also eine Schema-Änderung, keine Oberflächenfrage.
|
||||
|
||||
### Mitglied per E-Mail einladen
|
||||
|
||||
Die alte Einladung über eine E-Mail-Suche ist entfallen und durch „Mitglied
|
||||
anlegen" ersetzt. Sie konnte nie funktionieren: `users.listRule` gibt nur den
|
||||
eigenen Datensatz frei, die Suche lief immer ins Leere.
|
||||
|
||||
Ein **bestehendes** fremdes Konto in ein Team zu holen braucht eine offenere
|
||||
`listRule` (z. B. `@request.auth.id != ""`). Das erlaubt allerdings, alle
|
||||
Konten der Instanz aufzulisten — deshalb bewusst nicht ohne Rücksprache.
|
||||
|
||||
## 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 greifen beim nächsten Start
|
||||
|
||||
Fünf neue Migrationen liegen in `backend/pb_migrations/`:
|
||||
|
||||
- `1754500500_riders_user_and_admin_rules.js`
|
||||
- `1754500600_events_dates_instead_of_status.js`
|
||||
- `1754500700_trail_status_meta.js`
|
||||
- `1754500800_trail_markers_custom.js`
|
||||
- `1754500900_users_superadmin.js`
|
||||
|
||||
Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln
|
||||
aufbaut. Am deutlichsten beim Anlegen eines Mitglieds oder eines Fahrer-Logins:
|
||||
**„Only superusers can perform this action"** — genau das sagt die noch
|
||||
unveraenderte `users.createRule` (null = nur Superuser).
|
||||
|
||||
**Achtung:** Die mittlere entfernt `events.status` **samt seiner Werte**. Das
|
||||
ist so gewollt (Events werden datiert statt bewertet), aber es ist ein
|
||||
Datenverlust und nicht zurückzunehmen.
|
||||
|
||||
Ebenfalls neu: Fahrer anlegen, ändern und löschen darf ab dann nur noch die
|
||||
Teamleitung. Mitglieder ohne Admin-Rolle verlieren diese Möglichkeit auch auf
|
||||
der Event-Seite.
|
||||
|
||||
### Typen einmal neu erzeugen
|
||||
|
||||
`frontend/src/lib/types.d.ts` wurde bei den letzten Schema-Änderungen von Hand
|
||||
ergänzt (`riders.user`, `trails.status_by`/`status_changed`,
|
||||
`events.starts`/`ends` statt `status`). 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.
|
||||
|
||||
## Kleinigkeiten
|
||||
|
||||
- Der Melder eines Trail-Markers („wer") zeigt bis zum Deploy „Unbekannt". Die
|
||||
gelockerte `users.viewRule` kommt mit `1754500500`.
|
||||
- Die Rolle **Superadmin** existiert seit `1754500900` und ist für
|
||||
stammtisch@dne.name 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 bleiben **teamspezifisch**. Ein neues Team bekommt beim Anlegen
|
||||
den hartkodierten Standardsatz aus `trailFlags.svelte.ts`, den es danach frei
|
||||
ändern kann. Eine appweite Verwaltung gibt es damit bewusst nicht.
|
||||
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.
|
||||
|
|
@ -12,9 +12,9 @@ migrate((app) => {
|
|||
{
|
||||
"id": "_pb_users_auth_",
|
||||
"listRule": "id = @request.auth.id",
|
||||
"viewRule": "id = @request.auth.id",
|
||||
"createRule": null,
|
||||
"updateRule": "id = @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": "@request.auth.id != \"\"",
|
||||
"updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false",
|
||||
"deleteRule": "id = @request.auth.id",
|
||||
"name": "users",
|
||||
"type": "auth",
|
||||
|
|
@ -125,6 +125,15 @@ migrate((app) => {
|
|||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool_user_superadmin",
|
||||
"name": "superadmin",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
|
|
@ -457,20 +466,26 @@ migrate((app) => {
|
|||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select2063623452",
|
||||
"maxSelect": 1,
|
||||
"name": "status",
|
||||
"id": "date_event_starts",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "starts",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"draft",
|
||||
"active",
|
||||
"finished"
|
||||
]
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "date_event_ends",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "ends",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
|
|
@ -590,9 +605,9 @@ migrate((app) => {
|
|||
"id": "spgs7e4anufen9u",
|
||||
"listRule": "team.users.id ?= @request.auth.id",
|
||||
"viewRule": "team.users.id ?= @request.auth.id",
|
||||
"createRule": "team.users.id ?= @request.auth.id",
|
||||
"updateRule": "team.users.id ?= @request.auth.id",
|
||||
"deleteRule": "team.users.id ?= @request.auth.id",
|
||||
"createRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
|
||||
"updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
|
||||
"deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
|
||||
"name": "riders",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
|
|
@ -655,6 +670,20 @@ migrate((app) => {
|
|||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation_rider_user",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "user",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
|
|
@ -1013,6 +1042,31 @@ migrate((app) => {
|
|||
"gesperrt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "date_trail_status_changed",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "status_changed",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation_trail_status_by",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "status_by",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
|
|
@ -1467,10 +1521,40 @@ migrate((app) => {
|
|||
"minSelect": 0,
|
||||
"name": "flag",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text_marker_label",
|
||||
"max": 120,
|
||||
"min": 0,
|
||||
"name": "label",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text_marker_icon",
|
||||
"max": 60,
|
||||
"min": 0,
|
||||
"name": "icon",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Fahrer bekommen ein optionales Benutzerkonto, und die Fahrerverwaltung wird
|
||||
// zur Sache der Teamleitung.
|
||||
//
|
||||
// 1) riders.user — Verknüpfung eines Fahrers mit einem Login. Optional, weil
|
||||
// die meisten Fahrer nur in Ergebnislisten auftauchen und sich nie
|
||||
// anmelden. cascadeDelete ist bewusst aus: Wird ein Konto gelöscht, soll
|
||||
// der Fahrer samt seiner Zeiten erhalten bleiben.
|
||||
//
|
||||
// 2) riders create/update/delete nur noch für Owner und Admins. Bisher durfte
|
||||
// jedes Teammitglied Fahrer anlegen, ändern und löschen — mit den Zeiten
|
||||
// daran ist das zu viel Vertrauen in jeden Mitfahrer. Lesen bleibt für das
|
||||
// ganze Team offen.
|
||||
//
|
||||
// 3) users.viewRule von "nur ich selbst" auf "eingeloggt" gelockert. Ohne das
|
||||
// lässt sich zu einer bekannten User-ID kein Name anzeigen — betrifft die
|
||||
// Mitgliederliste eines Teams, den Melder eines Trail-Markers und die
|
||||
// Kontoverknüpfung eines Fahrers. Die listRule bleibt eng: Wer keine ID
|
||||
// kennt, kann auch keine Konten durchblättern. E-Mail-Adressen bleiben
|
||||
// über emailVisibility verborgen.
|
||||
//
|
||||
// 4) users.createRule von "niemand" auf "eingeloggt". Damit kann ein Admin
|
||||
// einem Fahrer direkt ein Login anlegen. Bewusst nicht offen (""), sonst
|
||||
// wäre es eine öffentliche Registrierung.
|
||||
|
||||
const RIDERS_ADMIN_RULE =
|
||||
'team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id'
|
||||
const RIDERS_MEMBER_RULE = 'team.users.id ?= @request.auth.id'
|
||||
const LOGGED_IN = '@request.auth.id != ""'
|
||||
|
||||
migrate((app) => {
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
const riders = app.findCollectionByNameOrId('riders')
|
||||
|
||||
// Der Snapshot in 1754400000_init_schema.js führt das Feld inzwischen
|
||||
// mit. Auf einer frischen Datenbank ist es also schon da, auf einer
|
||||
// bestehenden noch nicht — dieselbe Lage wie bei den trail-Collections.
|
||||
if (!riders.fields.getByName('user')) {
|
||||
riders.fields.add(new Field({
|
||||
id: 'relation_rider_user',
|
||||
name: 'user',
|
||||
type: 'relation',
|
||||
collectionId: users.id,
|
||||
cascadeDelete: false,
|
||||
maxSelect: 1,
|
||||
minSelect: 0,
|
||||
required: false,
|
||||
}))
|
||||
}
|
||||
|
||||
riders.createRule = RIDERS_ADMIN_RULE
|
||||
riders.updateRule = RIDERS_ADMIN_RULE
|
||||
riders.deleteRule = RIDERS_ADMIN_RULE
|
||||
app.save(riders)
|
||||
|
||||
users.viewRule = LOGGED_IN
|
||||
users.createRule = LOGGED_IN
|
||||
app.save(users)
|
||||
}, (app) => {
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
const riders = app.findCollectionByNameOrId('riders')
|
||||
|
||||
riders.fields.removeByName('user')
|
||||
riders.createRule = RIDERS_MEMBER_RULE
|
||||
riders.updateRule = RIDERS_MEMBER_RULE
|
||||
riders.deleteRule = RIDERS_MEMBER_RULE
|
||||
app.save(riders)
|
||||
|
||||
users.viewRule = 'id = @request.auth.id'
|
||||
users.createRule = null
|
||||
app.save(users)
|
||||
})
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Events werden datiert statt bewertet.
|
||||
//
|
||||
// Der Status (draft/active/finished) war Handarbeit: Jemand musste ein Event
|
||||
// aktiv schalten und hinterher wieder abschließen, sonst stand die Übersicht
|
||||
// falsch da. Ein Datum weiß von selbst, was ansteht, was läuft und was vorbei
|
||||
// ist.
|
||||
//
|
||||
// starts trägt den Tag; ends bleibt leer, wenn das Event nur einen Tag dauert,
|
||||
// und trägt sonst den letzten Tag. Beide sind optional, damit bestehende
|
||||
// Events ohne Datum weiterleben, bis jemand eines nachträgt.
|
||||
|
||||
migrate((app) => {
|
||||
const events = app.findCollectionByNameOrId('events')
|
||||
|
||||
// Auf einer frischen Datenbank hat der Snapshot die Felder bereits.
|
||||
if (!events.fields.getByName('starts')) {
|
||||
events.fields.add(new Field({
|
||||
id: 'date_event_starts',
|
||||
name: 'starts',
|
||||
type: 'date',
|
||||
}))
|
||||
}
|
||||
if (!events.fields.getByName('ends')) {
|
||||
events.fields.add(new Field({
|
||||
id: 'date_event_ends',
|
||||
name: 'ends',
|
||||
type: 'date',
|
||||
}))
|
||||
}
|
||||
|
||||
if (events.fields.getByName('status')) {
|
||||
events.fields.removeByName('status')
|
||||
}
|
||||
|
||||
app.save(events)
|
||||
}, (app) => {
|
||||
const events = app.findCollectionByNameOrId('events')
|
||||
|
||||
events.fields.removeByName('starts')
|
||||
events.fields.removeByName('ends')
|
||||
events.fields.add(new Field({
|
||||
id: 'select_event_status',
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
maxSelect: 1,
|
||||
values: ['draft', 'active', 'finished'],
|
||||
}))
|
||||
|
||||
app.save(events)
|
||||
})
|
||||
45
backend/pb_migrations/1754500700_trail_status_meta.js
Normal file
45
backend/pb_migrations/1754500700_trail_status_meta.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Wer hat den Trail gesperrt, und wann?
|
||||
//
|
||||
// Der Status allein sagt nur, wie es gerade steht. Für eine Meldung, der man
|
||||
// trauen soll, gehört dazu, wann sie kam und von wem — eine Sperrung von
|
||||
// heute Morgen wiegt anders als eine vom letzten Herbst.
|
||||
//
|
||||
// Beide Felder schreibt die Anwendung beim Setzen des Status mit; bestehende
|
||||
// Trails bleiben ohne Angabe, bis der Status das nächste Mal geändert wird.
|
||||
|
||||
migrate((app) => {
|
||||
const trails = app.findCollectionByNameOrId('trails')
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
|
||||
if (!trails.fields.getByName('status_changed')) {
|
||||
trails.fields.add(new Field({
|
||||
id: 'date_trail_status_changed',
|
||||
name: 'status_changed',
|
||||
type: 'date',
|
||||
}))
|
||||
}
|
||||
|
||||
if (!trails.fields.getByName('status_by')) {
|
||||
trails.fields.add(new Field({
|
||||
id: 'relation_trail_status_by',
|
||||
name: 'status_by',
|
||||
type: 'relation',
|
||||
collectionId: users.id,
|
||||
cascadeDelete: false,
|
||||
maxSelect: 1,
|
||||
minSelect: 0,
|
||||
required: false,
|
||||
}))
|
||||
}
|
||||
|
||||
app.save(trails)
|
||||
}, (app) => {
|
||||
const trails = app.findCollectionByNameOrId('trails')
|
||||
|
||||
trails.fields.removeByName('status_changed')
|
||||
trails.fields.removeByName('status_by')
|
||||
|
||||
app.save(trails)
|
||||
})
|
||||
57
backend/pb_migrations/1754500800_trail_markers_custom.js
Normal file
57
backend/pb_migrations/1754500800_trail_markers_custom.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Marker dürfen einen eigenen Text und ein eigenes Icon tragen.
|
||||
//
|
||||
// Bisher musste jede Meldung auf einen vorgefertigten Flag-Typ zeigen. Auf
|
||||
// dem Trail passt aber nicht immer einer: „Wespennest am Anlieger" legt
|
||||
// niemand vorher als Typ an. Deshalb:
|
||||
//
|
||||
// - flag ist nicht mehr Pflicht. Fehlt es, trägt der Marker seinen eigenen
|
||||
// Text in label.
|
||||
// - icon steht am Marker selbst. Beim Wählen eines Typs kommt dessen Icon als
|
||||
// Vorschlag; ändern lässt es sich trotzdem, denn dieselbe Art Hindernis
|
||||
// sieht nicht immer gleich aus.
|
||||
//
|
||||
// Bestehende Marker bleiben unverändert gültig: Sie haben ein flag und weder
|
||||
// label noch icon, die Anzeige fällt dann auf den Typ zurück.
|
||||
|
||||
migrate((app) => {
|
||||
const markers = app.findCollectionByNameOrId('trail_markers')
|
||||
|
||||
const flag = markers.fields.getByName('flag')
|
||||
if (flag) {
|
||||
flag.required = false
|
||||
}
|
||||
|
||||
if (!markers.fields.getByName('label')) {
|
||||
markers.fields.add(new Field({
|
||||
id: 'text_marker_label',
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
max: 120,
|
||||
}))
|
||||
}
|
||||
|
||||
if (!markers.fields.getByName('icon')) {
|
||||
markers.fields.add(new Field({
|
||||
id: 'text_marker_icon',
|
||||
name: 'icon',
|
||||
type: 'text',
|
||||
max: 60,
|
||||
}))
|
||||
}
|
||||
|
||||
app.save(markers)
|
||||
}, (app) => {
|
||||
const markers = app.findCollectionByNameOrId('trail_markers')
|
||||
|
||||
const flag = markers.fields.getByName('flag')
|
||||
if (flag) {
|
||||
flag.required = true
|
||||
}
|
||||
|
||||
markers.fields.removeByName('label')
|
||||
markers.fields.removeByName('icon')
|
||||
|
||||
app.save(markers)
|
||||
})
|
||||
47
backend/pb_migrations/1754500900_users_superadmin.js
Normal file
47
backend/pb_migrations/1754500900_users_superadmin.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Superadmin als appweite Rolle, über allen Teams.
|
||||
//
|
||||
// Wichtig ist die Ergänzung der updateRule: Jeder darf seinen eigenen
|
||||
// Datensatz ändern — ohne `@request.body.superadmin:isset = false` könnte
|
||||
// sich damit jeder selbst befördern. Vergeben wird die Rolle deshalb nur
|
||||
// direkt in PocketBase oder durch eine Migration wie diese.
|
||||
//
|
||||
// Der erste Superadmin ist stammtisch@dne.name. Existiert das Konto (noch)
|
||||
// nicht, passiert hier nichts weiter — die Rolle lässt sich jederzeit im
|
||||
// Admin-UI nachziehen.
|
||||
//
|
||||
// Flag-Typen bleiben ausdrücklich Sache der Teams: Jedes Team verwaltet
|
||||
// seine eigenen und bekommt beim Anlegen den Standardsatz aus der App.
|
||||
// Superadmin hat damit im Alltag nichts zu tun.
|
||||
|
||||
const FIRST_SUPERADMIN = 'stammtisch@dne.name'
|
||||
|
||||
migrate((app) => {
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
|
||||
if (!users.fields.getByName('superadmin')) {
|
||||
users.fields.add(new Field({
|
||||
id: 'bool_user_superadmin',
|
||||
name: 'superadmin',
|
||||
type: 'bool',
|
||||
}))
|
||||
}
|
||||
|
||||
users.updateRule = 'id = @request.auth.id && @request.body.superadmin:isset = false'
|
||||
app.save(users)
|
||||
|
||||
try {
|
||||
const first = app.findAuthRecordByEmail('users', FIRST_SUPERADMIN)
|
||||
first.set('superadmin', true)
|
||||
app.save(first)
|
||||
} catch {
|
||||
// Konto noch nicht angelegt — die Rolle wird dann von Hand vergeben.
|
||||
}
|
||||
}, (app) => {
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
|
||||
users.fields.removeByName('superadmin')
|
||||
users.updateRule = 'id = @request.auth.id'
|
||||
app.save(users)
|
||||
})
|
||||
78
frontend/src/lib/components/Avatar.svelte
Normal file
78
frontend/src/lib/components/Avatar.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Bild eines Nutzers — und wenn keins hinterlegt ist, ein erzeugtes aus
|
||||
* Initialen und einer Farbe.
|
||||
*
|
||||
* Die Farbe kommt aus einer Prüfsumme über die ID: Derselbe Mensch bekommt
|
||||
* damit überall dasselbe Bild, ohne dass es irgendwo gespeichert werden
|
||||
* müsste. Bewusst kein externer Avatar-Dienst — der würde bei jedem
|
||||
* Seitenaufruf verraten, wer hier gerade unterwegs ist.
|
||||
*/
|
||||
import { getFileURL } from '$lib/stores/pocketbase.svelte'
|
||||
|
||||
let {
|
||||
user = null,
|
||||
size = 32,
|
||||
class: className = '',
|
||||
}: {
|
||||
user?: { id?: string; name?: string; email?: string; avatar?: string } | null
|
||||
size?: number
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
const label = $derived(user?.name?.trim() || user?.email?.trim() || '')
|
||||
|
||||
/** Erste Buchstaben der ersten beiden Wörter, sonst der erste des Namens. */
|
||||
const initials = $derived.by(() => {
|
||||
if (!label) return '?'
|
||||
|
||||
const words = label.split(/[\s.@_-]+/).filter(Boolean)
|
||||
if (words.length === 0) return '?'
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
|
||||
|
||||
return (words[0][0] + words[1][0]).toUpperCase()
|
||||
})
|
||||
|
||||
/**
|
||||
* Farbton aus der ID, hilfsweise aus dem Namen. Sättigung und Helligkeit
|
||||
* bleiben fest, damit weiße Schrift auf jedem Ton lesbar bleibt — auch im
|
||||
* Dunkelmodus, wo eine zufällige Helligkeit schnell blendet.
|
||||
*/
|
||||
const hue = $derived.by(() => {
|
||||
const seed = user?.id || label || '?'
|
||||
let h = 0
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = (h * 31 + seed.charCodeAt(i)) % 360
|
||||
}
|
||||
return h
|
||||
})
|
||||
|
||||
const src = $derived(
|
||||
user?.avatar
|
||||
? getFileURL(user as object, user.avatar, { thumb: `${size * 2}x${size * 2}` })
|
||||
: null,
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if src}
|
||||
<img
|
||||
{src}
|
||||
alt={label}
|
||||
width={size}
|
||||
height={size}
|
||||
class="shrink-0 rounded-full object-cover {className}"
|
||||
style:width="{size}px"
|
||||
style:height="{size}px"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="grid shrink-0 place-items-center rounded-full font-medium text-white select-none {className}"
|
||||
style:width="{size}px"
|
||||
style:height="{size}px"
|
||||
style:background="hsl({hue} 55% 42%)"
|
||||
style:font-size="{Math.round(size * 0.4)}px"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
{/if}
|
||||
|
|
@ -1,17 +1,28 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente die
|
||||
* Distanz des Punktes; die Elternkomponente reicht sie an die Karte
|
||||
* weiter, damit dort dieselbe Stelle hervorgehoben wird. Distanz statt
|
||||
* Index, weil die Karte eine andere (vereinfachte) Punktreihe zeigt.
|
||||
* Höhenprofil als SVG-Fläche. Die hervorgehobene Stelle kommt als Prop
|
||||
* herein und geht beim Überfahren als Callback hinaus — die Elternseite
|
||||
* hält den Wert und reicht ihn ebenso an die Karte weiter. So zeigen
|
||||
* Karte und Profil immer dieselbe Stelle, egal wo gerade gezeigt wird.
|
||||
* Gekoppelt wird über die Distanz, nicht über den Index: Die Karte zeigt
|
||||
* eine vereinfachte Punktreihe, das Profil alle Originalpunkte.
|
||||
*/
|
||||
let {
|
||||
elevation,
|
||||
onhover,
|
||||
onselect,
|
||||
highlightDistance = null,
|
||||
height = '120px',
|
||||
}: {
|
||||
elevation: { d: number; ele: number }[]
|
||||
onhover?: (distance: number | null) => void
|
||||
/**
|
||||
* Klick auf eine Stelle des Profils. Damit lässt sich ein Marker auch
|
||||
* über die Höhe finden — im steilen Stück, statt die Linie auf der
|
||||
* Karte abzusuchen.
|
||||
*/
|
||||
onselect?: (distance: number) => void
|
||||
highlightDistance?: number | null
|
||||
height?: string
|
||||
} = $props()
|
||||
|
||||
|
|
@ -46,42 +57,148 @@
|
|||
return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z`
|
||||
})
|
||||
|
||||
let hoverX = $state<number | null>(null)
|
||||
/**
|
||||
* Der Punkt zur hervorgehobenen Distanz. Gesucht wird binär, weil das
|
||||
* Profil alle Originalpunkte enthält — bei jeder Mausbewegung linear
|
||||
* durch mehrere tausend Einträge zu laufen wäre unnötig teuer.
|
||||
*/
|
||||
const hover = $derived.by(() => {
|
||||
if (!stats || highlightDistance == null) return null
|
||||
|
||||
function handleMove(e: MouseEvent) {
|
||||
if (!elevation.length) return
|
||||
let lo = 0
|
||||
let hi = elevation.length - 1
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (elevation[mid].d <= highlightDistance) lo = mid
|
||||
else hi = mid
|
||||
}
|
||||
|
||||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect()
|
||||
const p =
|
||||
Math.abs(elevation[lo].d - highlightDistance) <=
|
||||
Math.abs(elevation[hi].d - highlightDistance)
|
||||
? elevation[lo]
|
||||
: elevation[hi]
|
||||
|
||||
return {
|
||||
ele: p.ele,
|
||||
d: p.d,
|
||||
// In Prozent, damit die HTML-Overlays dieselbe Stelle treffen wie
|
||||
// das über viewBox skalierte SVG.
|
||||
left: (p.d / stats.totalD) * 100,
|
||||
top: (1 - (p.ele - stats.min) / stats.span) * 100,
|
||||
}
|
||||
})
|
||||
|
||||
/** Distanz an der Mausposition; null, wenn es keine Punkte gibt. */
|
||||
function distanceAt(e: MouseEvent): number | null {
|
||||
if (!elevation.length) return null
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const ratio = (e.clientX - rect.left) / rect.width
|
||||
const idx = Math.round(ratio * (elevation.length - 1))
|
||||
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
|
||||
|
||||
hoverX = ratio * W
|
||||
onhover?.(elevation[clamped].d)
|
||||
return elevation[clamped].d
|
||||
}
|
||||
|
||||
function handleMove(e: MouseEvent) {
|
||||
const d = distanceAt(e)
|
||||
if (d !== null) onhover?.(d)
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!onselect) return
|
||||
|
||||
const d = distanceAt(e)
|
||||
if (d !== null) onselect(d)
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
hoverX = null
|
||||
onhover?.(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tastaturweg zum Setzen: Mit den Pfeiltasten die Stelle wählen, mit
|
||||
* Enter oder Leertaste bestätigen. Ohne das wäre das Profil ein reines
|
||||
* Mausziel.
|
||||
*/
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (!onselect || !stats) return
|
||||
|
||||
const total = stats.totalD
|
||||
const step = total / 50
|
||||
const current = highlightDistance ?? 0
|
||||
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
const next = e.key === 'ArrowRight' ? current + step : current - step
|
||||
onhover?.(Math.max(0, Math.min(total, next)))
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (highlightDistance != null) onselect(highlightDistance)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stats}
|
||||
<div class="w-full" style:height>
|
||||
<div class="relative w-full" style:height>
|
||||
<!--
|
||||
Die Fläche ist ein echter Knopf: Sie reagiert auf Zeiger und
|
||||
Tastatur, und ein <svg> mit Rollen und tabindex bleibt für
|
||||
Hilfsmittel ein Bild mit angeklebtem Verhalten.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full h-full border-0 bg-transparent p-0"
|
||||
class:cursor-pointer={!!onselect}
|
||||
aria-label={onselect
|
||||
? 'Höhenprofil – hier klicken setzt einen Marker an dieser Stelle'
|
||||
: 'Höhenprofil'}
|
||||
onmousemove={handleMove}
|
||||
onmouseleave={handleLeave}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKey}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 {W} {H}"
|
||||
preserveAspectRatio="none"
|
||||
class="w-full h-full"
|
||||
role="img"
|
||||
aria-label="Höhenprofil"
|
||||
onmousemove={handleMove}
|
||||
onmouseleave={handleLeave}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
|
||||
{#if hoverX !== null}
|
||||
<line x1={hoverX} y1="0" x2={hoverX} y2={H} stroke="rgb(14 165 233)" stroke-width="1" vector-effect="non-scaling-stroke" />
|
||||
{#if hover}
|
||||
<line
|
||||
x1={(hover.left / 100) * W}
|
||||
y1="0"
|
||||
x2={(hover.left / 100) * W}
|
||||
y2={H}
|
||||
stroke="rgb(14 165 233)"
|
||||
stroke-width="1"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if hover}
|
||||
<!--
|
||||
Punkt und Beschriftung liegen als HTML über dem SVG: dessen
|
||||
viewBox wird ungleichmäßig gestreckt, ein Kreis darin würde
|
||||
zur Ellipse und Text mitverzerrt.
|
||||
-->
|
||||
<span
|
||||
class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-500 ring-2 ring-background"
|
||||
style:left="{hover.left}%"
|
||||
style:top="{hover.top}%"
|
||||
></span>
|
||||
<span
|
||||
class="pointer-events-none absolute top-0 -translate-x-1/2 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium whitespace-nowrap shadow-sm"
|
||||
style:left="clamp(2.5rem, {hover.left}%, calc(100% - 2.5rem))"
|
||||
>
|
||||
{Math.round(hover.ele)} m · {(hover.d / 1000).toFixed(2)} km
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>{Math.round(stats.min)} m</span>
|
||||
|
|
|
|||
34
frontend/src/lib/components/FlagIcon.svelte
Normal file
34
frontend/src/lib/components/FlagIcon.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Rendert das Icon eines Flag-Typs. Der Name kommt aus der Datenbank und
|
||||
* ist Freitext — solange geladen wird und bei unbekannten Namen steht
|
||||
* ersatzweise die Flagge da, statt eine Lücke zu lassen.
|
||||
*/
|
||||
import { Flag } from 'lucide-svelte'
|
||||
import { iconLoader } from '$lib/flagIcons'
|
||||
|
||||
let {
|
||||
name,
|
||||
color,
|
||||
class: className = 'size-4',
|
||||
}: { name?: string; color?: string; class?: string } = $props()
|
||||
|
||||
// Das Versprechen einmal je Name bilden, nicht bei jedem Rendern neu.
|
||||
const loading = $derived(iconLoader(name)?.() ?? null)
|
||||
</script>
|
||||
|
||||
<!-- Farbe über den Wrapper: Lucide zeichnet mit currentColor. -->
|
||||
<span class="inline-flex shrink-0" style:color>
|
||||
{#if loading}
|
||||
{#await loading}
|
||||
<Flag class={className} />
|
||||
{:then mod}
|
||||
{@const Icon = mod.default as typeof Flag}
|
||||
<Icon class={className} />
|
||||
{:catch}
|
||||
<Flag class={className} />
|
||||
{/await}
|
||||
{:else}
|
||||
<Flag class={className} />
|
||||
{/if}
|
||||
</span>
|
||||
64
frontend/src/lib/components/IconPicker.svelte
Normal file
64
frontend/src/lib/components/IconPicker.svelte
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Icon-Auswahl: Vorschau, Freitextfeld und ein Raster mit Vorschlägen.
|
||||
* Erlaubt ist jeder Name von lucide.dev — die Vorschläge sind nur die
|
||||
* Abkürzung fürs häufig Gebrauchte.
|
||||
*
|
||||
* Gemeinsam genutzt von der Flag-Verwaltung und dem Marker-Dialog, damit
|
||||
* beide dieselbe Auswahl zeigen.
|
||||
*/
|
||||
import FlagIcon from './FlagIcon.svelte'
|
||||
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
color = undefined,
|
||||
id = 'icon-picker',
|
||||
label = 'Icon',
|
||||
}: {
|
||||
value?: string
|
||||
/** Färbt Vorschau und Vorschläge, damit man das Ergebnis sieht. */
|
||||
color?: string
|
||||
id?: string
|
||||
label?: string
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for={id}>{label}</Label>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="grid size-9 shrink-0 place-items-center rounded-md border">
|
||||
<FlagIcon name={value} {color} class="size-5" />
|
||||
</span>
|
||||
<Input {id} bind:value placeholder="z. B. TreePine" class="flex-1" />
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Jeder Name von <a href="https://lucide.dev/icons" target="_blank" rel="noreferrer" class="underline">lucide.dev</a>
|
||||
ist möglich — geschrieben wie „TreePine" oder „tree-pine".
|
||||
{#if value.trim() && !iconExists(value)}
|
||||
<span class="text-destructive">Diesen Namen gibt es dort nicht.</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-10 gap-1 rounded-lg border p-2">
|
||||
{#each FLAG_ICON_SUGGESTIONS as name (name)}
|
||||
<button
|
||||
type="button"
|
||||
title={name}
|
||||
aria-label={name}
|
||||
aria-pressed={value === name}
|
||||
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
|
||||
class:bg-accent={value === name}
|
||||
class:ring-2={value === name}
|
||||
class:ring-primary={value === name}
|
||||
onclick={() => (value = name)}
|
||||
>
|
||||
<FlagIcon {name} {color} class="size-4" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
149
frontend/src/lib/components/RunningTimesToast.svelte
Normal file
149
frontend/src/lib/components/RunningTimesToast.svelte
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
|
||||
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
|
||||
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
|
||||
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Run-Seite.
|
||||
*
|
||||
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
|
||||
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
|
||||
*
|
||||
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
|
||||
* Zeit-Dialog mit Fahrer und Run vorbelegt.
|
||||
*/
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import TimeDialog from './TimeDialog.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronDown, ChevronUp, Timer } from 'lucide-svelte'
|
||||
|
||||
const times = getTimeContext()
|
||||
const riders = getRiderContext()
|
||||
const runs = getRunContext()
|
||||
const clock = ticker()
|
||||
|
||||
const running = $derived(times.allRunning(clock.now))
|
||||
const single = $derived(running.length === 1)
|
||||
|
||||
let expanded = $state(false)
|
||||
|
||||
// Vorbelegung für den Zeit-Dialog
|
||||
let timeDialog = $state(false)
|
||||
let dialogEvent = $state('')
|
||||
let dialogRider = $state<string | null>(null)
|
||||
let dialogRun = $state<string | null>(null)
|
||||
|
||||
function openTimeDialog(riderId: string | undefined, runId: string | undefined) {
|
||||
const run = runId ? runs.getById(runId) : undefined
|
||||
if (!run?.event) return
|
||||
|
||||
dialogEvent = run.event
|
||||
dialogRider = riderId ?? null
|
||||
dialogRun = run.id
|
||||
timeDialog = true
|
||||
}
|
||||
|
||||
// Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der
|
||||
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
|
||||
const open = $derived(single || expanded)
|
||||
|
||||
function riderLabel(riderId: string | undefined) {
|
||||
const r = riderId ? riders.getById(riderId) : undefined
|
||||
if (!r) return 'Unbekannter Fahrer'
|
||||
return `#${r.number || '—'} ${fullName(r)}`.trim()
|
||||
}
|
||||
|
||||
function runPath(runId: string | undefined) {
|
||||
const run = runId ? runs.getById(runId) : undefined
|
||||
if (!run?.event) return null
|
||||
return `/dashboard/events/${run.event}/runs/${run.id}`
|
||||
}
|
||||
|
||||
function runLabel(runId: string | undefined) {
|
||||
const run = runId ? runs.getById(runId) : undefined
|
||||
return run?.description || 'Run'
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if running.length > 0}
|
||||
<!-- fixed statt sticky: Die Leiste soll auch beim Scrollen stehen bleiben. -->
|
||||
<div class="fixed bottom-4 right-4 z-50 w-[min(24rem,calc(100vw-2rem))]">
|
||||
<div class="rounded-lg border bg-card text-card-foreground shadow-lg overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b bg-primary/5">
|
||||
<span class="relative flex size-2 shrink-0">
|
||||
<span class="absolute inline-flex h-full w-full rounded-full bg-primary opacity-60 animate-ping"></span>
|
||||
<span class="relative inline-flex size-2 rounded-full bg-primary"></span>
|
||||
</span>
|
||||
|
||||
<span class="text-sm font-medium">
|
||||
{running.length === 1 ? 'Zeit läuft' : `${running.length} Zeiten laufen`}
|
||||
</span>
|
||||
|
||||
{#if !open}
|
||||
<span class="text-sm tabular-nums font-semibold ml-auto">
|
||||
{running[0].formattedTime}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if !single}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="ml-auto size-7 shrink-0"
|
||||
title={expanded ? 'Einklappen' : 'Alle anzeigen'}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
{#if expanded}
|
||||
<ChevronDown class="size-4" />
|
||||
{:else}
|
||||
<ChevronUp class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<ul class="divide-y max-h-[50vh] overflow-y-auto">
|
||||
{#each running as t (t.id)}
|
||||
{@const path = runPath(t.run)}
|
||||
<li class="flex items-center gap-2 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{riderLabel(t.rider)}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{#if path}
|
||||
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
|
||||
{:else}
|
||||
{runLabel(t.run)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class="text-lg font-semibold tabular-nums shrink-0">
|
||||
{t.formattedTime}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="shrink-0"
|
||||
title="Zeit erfassen"
|
||||
onclick={() => openTimeDialog(t.rider, t.run)}
|
||||
>
|
||||
<Timer class="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimeDialog
|
||||
bind:open={timeDialog}
|
||||
eventId={dialogEvent}
|
||||
riderId={dialogRider}
|
||||
runId={dialogRun}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={() => goto('/dashboard/teams')}>
|
||||
<DropdownMenu.Item onclick={() => goto('/dashboard/settings/team')}>
|
||||
<Users class="h-4 w-4 mr-2" />
|
||||
Teams verwalten
|
||||
</DropdownMenu.Item>
|
||||
|
|
|
|||
219
frontend/src/lib/components/TimeDialog.svelte
Normal file
219
frontend/src/lib/components/TimeDialog.svelte
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Der eine Dialog für die Zeitnahme. Fahrer und Run stehen immer beide
|
||||
* darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit
|
||||
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
|
||||
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
|
||||
* Tabellen noch die Toast-Leiste lösen sie direkt aus.
|
||||
*/
|
||||
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import * as Dialog from '@/components/ui/dialog'
|
||||
import * as Select from '@/components/ui/select'
|
||||
import { AlertTriangle, Play, Square } from 'lucide-svelte'
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
eventId,
|
||||
riderId = null,
|
||||
runId = null,
|
||||
}: {
|
||||
open?: boolean
|
||||
/** Event, aus dem Fahrer und Runs zur Auswahl stehen. */
|
||||
eventId: string
|
||||
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
|
||||
riderId?: string | null
|
||||
runId?: string | null
|
||||
} = $props()
|
||||
|
||||
const times = getTimeContext()
|
||||
const riders = getRiderContext()
|
||||
const runs = getRunContext()
|
||||
const clock = ticker()
|
||||
|
||||
let selectedRider = $state('')
|
||||
let selectedRun = $state('')
|
||||
let error = $state<string | null>(null)
|
||||
let confirmDnf = $state(false)
|
||||
let busy = $state(false)
|
||||
|
||||
// Beim Öffnen auf die Vorbelegung zurücksetzen — ein Dialog, der noch die
|
||||
// Auswahl vom letzten Mal zeigt, ist an der Strecke eine Fehlerquelle.
|
||||
$effect(() => {
|
||||
if (!open) return
|
||||
|
||||
selectedRider = riderId ?? ''
|
||||
selectedRun = runId ?? ''
|
||||
error = null
|
||||
confirmDnf = false
|
||||
})
|
||||
|
||||
const eventRiders = $derived(eventId ? riders.getByEvent(eventId) : [])
|
||||
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
|
||||
|
||||
const riderLabel = $derived(
|
||||
selectedRider
|
||||
? (() => {
|
||||
const r = riders.getById(selectedRider)
|
||||
return r ? `#${r.number || '—'} ${fullName(r)}`.trim() : 'Fahrer wählen…'
|
||||
})()
|
||||
: 'Fahrer wählen…',
|
||||
)
|
||||
const runLabel = $derived(
|
||||
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
|
||||
)
|
||||
|
||||
const ready = $derived(!!selectedRider && !!selectedRun)
|
||||
|
||||
// Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und
|
||||
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
|
||||
const active = $derived(
|
||||
ready
|
||||
? times
|
||||
.allRunning(clock.now)
|
||||
.find((t) => t.run === selectedRun && t.rider === selectedRider)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
|
||||
const existing = $derived(
|
||||
ready && !active
|
||||
? times.getByRun(selectedRun).find((t) => t.rider === selectedRider && t.stop)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
async function start() {
|
||||
if (!ready || busy) return
|
||||
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
await times.start(selectedRun, selectedRider)
|
||||
open = false
|
||||
} catch (e: any) {
|
||||
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(status: 'finished' | 'dnf') {
|
||||
if (!active || busy) return
|
||||
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
await times.stop(active.id, status as TimeStatus)
|
||||
open = false
|
||||
} catch (e: any) {
|
||||
error = e.message ?? 'Die Zeit konnte nicht gestoppt werden.'
|
||||
} finally {
|
||||
busy = false
|
||||
confirmDnf = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Zeit erfassen</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Fahrer und Run wählen — je nach Stand wird gestartet oder gestoppt.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Fahrer</Label>
|
||||
<Select.Root type="single" bind:value={selectedRider} disabled={eventRiders.length === 0}>
|
||||
<Select.Trigger class="w-full">{riderLabel}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each eventRiders as r (r.id)}
|
||||
<Select.Item value={r.id}>
|
||||
#{r.number || '—'} {fullName(r)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if eventRiders.length === 0}
|
||||
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Fahrer.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Run</Label>
|
||||
<Select.Root type="single" bind:value={selectedRun} disabled={eventRuns.length === 0}>
|
||||
<Select.Trigger class="w-full">{runLabel}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each eventRuns as r (r.id)}
|
||||
<Select.Item value={r.id}>{r.description || 'Run'}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if eventRuns.length === 0}
|
||||
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Runs.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if active}
|
||||
<div class="rounded-lg border bg-primary/5 py-4 text-center">
|
||||
<p class="text-xs text-muted-foreground">läuft seit</p>
|
||||
<p class="font-mono text-4xl font-semibold tabular-nums">{active.formattedTime}</p>
|
||||
</div>
|
||||
{:else if existing}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Für diese Paarung gibt es bereits eine Zeit ({existing.formattedTime}).
|
||||
Ein neuer Start legt eine zweite an.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if confirmDnf}
|
||||
<!-- DNF beendet die Zeit ohne Wertung und ist nicht zurückzunehmen. -->
|
||||
<div class="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-3">
|
||||
<p class="text-sm flex items-start gap-2">
|
||||
<AlertTriangle class="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<span>
|
||||
Wirklich als DNF werten? Die Zeit wird beendet und taucht in keiner
|
||||
Rangliste auf.
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onclick={() => (confirmDnf = false)}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" disabled={busy} onclick={() => stop('dnf')}>
|
||||
DNF bestätigen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
|
||||
{#if active}
|
||||
<Button variant="outline" disabled={busy} onclick={() => (confirmDnf = true)}>
|
||||
DNF
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={busy} onclick={() => stop('finished')}>
|
||||
<Square class="h-4 w-4 mr-2" />
|
||||
Stoppen
|
||||
</Button>
|
||||
{:else}
|
||||
<Button disabled={!ready || busy} onclick={start}>
|
||||
<Play class="h-4 w-4 mr-2" />
|
||||
Starten
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -4,9 +4,9 @@
|
|||
* Props, Ereignisse gehen über Callback-Props zurück. Damit ist die
|
||||
* Komponente in Liste, Detailseite und Setzmodus gleichermaßen nutzbar.
|
||||
*/
|
||||
import { onMount } from 'svelte'
|
||||
import { mount, onMount, unmount } from 'svelte'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import type { Map as MapLibreMap, Marker } from 'maplibre-gl'
|
||||
import type { Map as MapLibreMap, Marker, Popup } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
// MapLibre 6 liegt als drei Dateien vor (Hauptdatei, shared, worker) und
|
||||
// lädt den Worker zur Laufzeit relativ zum eigenen Verzeichnis nach. Beides
|
||||
|
|
@ -22,6 +22,11 @@
|
|||
// Datei — ein blosses ?url würde nur die Rohdatei kopieren und ihren
|
||||
// relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404).
|
||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
|
||||
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx'
|
||||
import FlagIcon from './FlagIcon.svelte'
|
||||
import { trailStatusColor } from '$lib/trailStatus'
|
||||
import { env } from '$env/dynamic/public'
|
||||
import { mode } from 'mode-watcher'
|
||||
|
||||
maplibregl.setWorkerUrl(workerUrl)
|
||||
|
||||
|
|
@ -32,7 +37,15 @@
|
|||
lng: number
|
||||
color: string
|
||||
resolved: boolean
|
||||
/** Was — der Flag-Typ */
|
||||
label: string
|
||||
/** Icon-Name des Flag-Typs */
|
||||
icon?: string
|
||||
/** Wann — bereits formatiert */
|
||||
when?: string
|
||||
/** Wer — bereits aufgelöst */
|
||||
who?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -40,44 +53,89 @@
|
|||
bounds = null,
|
||||
markers = [],
|
||||
status = 'offen',
|
||||
placing = false,
|
||||
coordDistances = [],
|
||||
highlightDistance = null,
|
||||
onplace,
|
||||
onmarkerclick,
|
||||
ontrailhover,
|
||||
controls = true,
|
||||
height = '420px',
|
||||
}: {
|
||||
geojson?: LineGeoJSON | null
|
||||
bounds?: [[number, number], [number, number]] | null
|
||||
markers?: MarkerData[]
|
||||
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
|
||||
placing?: boolean
|
||||
coordDistances?: number[]
|
||||
highlightDistance?: number | null
|
||||
onplace?: (coords: { lat: number; lng: number }) => void
|
||||
onmarkerclick?: (id: string) => void
|
||||
/**
|
||||
* Wird nur beim Klick auf die Trail-Linie gerufen — und zwar mit der
|
||||
* darauf projizierten Stelle, nie mit dem rohen Klickpunkt. Ist der
|
||||
* Callback nicht gesetzt, ist die Linie nicht klickbar.
|
||||
*/
|
||||
onplace?: (p: { lat: number; lng: number; distance: number | null }) => void
|
||||
/** Streckenkilometer unter dem Zeiger, null beim Verlassen der Linie. */
|
||||
ontrailhover?: (distance: number | null) => void
|
||||
/**
|
||||
* Zoom-Knöpfe, Maßstab und Quellenangabe. In der Kachelvorschau
|
||||
* abgeschaltet — dort ist die Karte Bild, nicht Werkzeug. Die
|
||||
* Quellenangabe zu OpenStreetMap trägt die große Karte.
|
||||
*/
|
||||
controls?: boolean
|
||||
height?: string
|
||||
} = $props()
|
||||
|
||||
// Kachelquelle als Konstante, damit sie sich später leicht gegen eine
|
||||
// Outdoor-Karte mit Höhenlinien tauschen lässt.
|
||||
const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
const ATTRIBUTION = '© OpenStreetMap-Mitwirkende'
|
||||
/**
|
||||
* Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen
|
||||
* keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt
|
||||
* dadurch deutlich sichtbarer darüber — und es gibt sie in einer echten
|
||||
* dunklen Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein
|
||||
* dunkleres Bild, keine dunkle Karte.
|
||||
*
|
||||
* Mehrere Subdomains, weil Browser die Verbindungen pro Host begrenzen.
|
||||
*/
|
||||
// Der Schlüssel muss den Browser erreichen — er kommt deshalb aus den
|
||||
// PUBLIC_-Variablen. Über $env/dynamic/public statt static, weil die
|
||||
// Kacheln auch ohne Schlüssel ausgeliefert werden: Fehlt die Variable,
|
||||
// soll der Build nicht abbrechen.
|
||||
const CARTO_KEY = env.PUBLIC_CARTO_API_KEY ?? ''
|
||||
const KEY_PARAM = CARTO_KEY ? `?key=${CARTO_KEY}` : ''
|
||||
|
||||
const cartoTiles = (style: 'light_all' | 'dark_all') =>
|
||||
['a', 'b', 'c'].map(
|
||||
(sub) => `https://${sub}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}.png${KEY_PARAM}`,
|
||||
)
|
||||
|
||||
const TILES_LIGHT = cartoTiles('light_all')
|
||||
const TILES_DARK = cartoTiles('dark_all')
|
||||
const ATTRIBUTION = '© OpenStreetMap-Mitwirkende, © CARTO'
|
||||
|
||||
const isDark = $derived(mode.current === 'dark')
|
||||
|
||||
// Fallback, wenn ein Trail noch keine Version hat: Hersbruck.
|
||||
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
|
||||
|
||||
const LINE_COLORS = {
|
||||
offen: '#16a34a',
|
||||
eingeschraenkt: '#ca8a04',
|
||||
gesperrt: '#dc2626',
|
||||
} as const
|
||||
|
||||
let container: HTMLDivElement
|
||||
let map = $state<MapLibreMap | null>(null)
|
||||
let ready = $state(false)
|
||||
let markerObjects: Marker[] = []
|
||||
let highlightMarker: Marker | null = null
|
||||
let popup: Popup | null = null
|
||||
|
||||
/**
|
||||
* MapLibre will für einen Marker ein DOM-Element, Icons kommen aber als
|
||||
* Svelte-Komponenten. `mount` schlägt die Brücke — dafür müssen die
|
||||
* Instanzen von Hand wieder abgeräumt werden, sonst bleiben sie beim
|
||||
* Neuaufbau der Marker hängen.
|
||||
*/
|
||||
let iconInstances: Record<string, unknown>[] = []
|
||||
|
||||
function mountIcon(target: HTMLElement, name: string | undefined, size: string) {
|
||||
iconInstances.push(mount(FlagIcon, { target, props: { name, class: size } }))
|
||||
}
|
||||
|
||||
function clearIcons() {
|
||||
for (const inst of iconInstances) unmount(inst)
|
||||
iconInstances = []
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cleanupMap: (() => void) | null = null
|
||||
|
|
@ -90,7 +148,7 @@
|
|||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: [TILES],
|
||||
tiles: isDark ? TILES_DARK : TILES_LIGHT,
|
||||
tileSize: 256,
|
||||
attribution: ATTRIBUTION,
|
||||
},
|
||||
|
|
@ -99,20 +157,19 @@
|
|||
},
|
||||
center: FALLBACK_CENTER,
|
||||
zoom: 11,
|
||||
attributionControl: controls ? undefined : false,
|
||||
})
|
||||
map = instance
|
||||
|
||||
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
|
||||
if (controls) {
|
||||
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
|
||||
}
|
||||
|
||||
instance.on('load', () => {
|
||||
ready = true
|
||||
})
|
||||
|
||||
instance.on('click', (e) => {
|
||||
if (placing) onplace?.({ lat: e.lngLat.lat, lng: e.lngLat.lng })
|
||||
})
|
||||
|
||||
cleanupMap = () => {
|
||||
// Map.remove() räumt Controls auf, aber keine Marker — die tragen
|
||||
// eigene move-Listener auf der Karte und werden hier zuerst entfernt.
|
||||
|
|
@ -120,6 +177,9 @@
|
|||
markerObjects = []
|
||||
highlightMarker?.remove()
|
||||
highlightMarker = null
|
||||
popup?.remove()
|
||||
popup = null
|
||||
clearIcons()
|
||||
|
||||
instance.remove()
|
||||
map = null
|
||||
|
|
@ -146,12 +206,58 @@
|
|||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Hover und Klick hängen an einer unsichtbaren, breiten Kopie der Linie:
|
||||
* die sichtbaren 4 px wären als Ziel zu schmal, und maplibre liefert
|
||||
* layerbezogene Ereignisse nur für das, was wirklich getroffen wurde.
|
||||
* Deshalb landet ein Klick neben dem Trail auch nirgends — Marker können
|
||||
* gar nicht abseits der Strecke entstehen.
|
||||
*
|
||||
* Registriert wird einmalig direkt nach dem Anlegen der Layer; vorher
|
||||
* hätten die layerbezogenen Listener kein Ziel.
|
||||
*/
|
||||
function bindTrailEvents(instance: MapLibreMap) {
|
||||
const project = (e: maplibregl.MapMouseEvent) => {
|
||||
const coords = geojson?.coordinates
|
||||
if (!coords?.length) return null
|
||||
|
||||
const pos = nearestOnLine(coords, e.lngLat.lng, e.lngLat.lat)
|
||||
if (!pos) return null
|
||||
|
||||
return { pos, distance: distanceAlong(pos, coordDistances) }
|
||||
}
|
||||
|
||||
instance.on('mousemove', 'trail-hit', (e) => {
|
||||
const hit = project(e)
|
||||
if (!hit) return
|
||||
|
||||
instance.getCanvas().style.cursor = onplace ? 'pointer' : ''
|
||||
ontrailhover?.(hit.distance)
|
||||
})
|
||||
|
||||
instance.on('mouseleave', 'trail-hit', () => {
|
||||
instance.getCanvas().style.cursor = ''
|
||||
ontrailhover?.(null)
|
||||
})
|
||||
|
||||
instance.on('click', 'trail-hit', (e) => {
|
||||
if (!onplace) return
|
||||
|
||||
const hit = project(e)
|
||||
if (!hit) return
|
||||
|
||||
// Ein offenes Marker-Popup gehört zur vorherigen Stelle.
|
||||
popup?.remove()
|
||||
onplace({ lat: hit.pos.lat, lng: hit.pos.lng, distance: hit.distance })
|
||||
})
|
||||
}
|
||||
|
||||
// Trail-Linie zeichnen bzw. aktualisieren
|
||||
$effect(() => {
|
||||
if (!map || !ready) return
|
||||
|
||||
const data = geojson
|
||||
const color = LINE_COLORS[status] ?? LINE_COLORS.offen
|
||||
const color = trailStatusColor(status)
|
||||
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
|
||||
|
||||
if (!data) {
|
||||
|
|
@ -175,22 +281,83 @@
|
|||
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. */
|
||||
$effect(() => {
|
||||
if (!map || !ready) return
|
||||
|
||||
const src = map.getSource('osm') as maplibregl.RasterTileSource | undefined
|
||||
src?.setTiles(isDark ? TILES_DARK : TILES_LIGHT)
|
||||
})
|
||||
|
||||
// Kartenausschnitt auf den Track setzen
|
||||
$effect(() => {
|
||||
if (!map || !ready || !bounds) return
|
||||
map.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 16 })
|
||||
})
|
||||
|
||||
/** Popup-Inhalt zu einem Marker: was, wann, wer — plus Notiz. */
|
||||
function popupContent(data: MarkerData): HTMLElement {
|
||||
const root = document.createElement('div')
|
||||
root.className = 'trail-popup'
|
||||
|
||||
const head = document.createElement('div')
|
||||
head.className = 'trail-popup-head'
|
||||
|
||||
const icon = document.createElement('span')
|
||||
icon.className = 'trail-popup-icon'
|
||||
icon.style.color = data.color
|
||||
mountIcon(icon, data.icon, 'size-4')
|
||||
head.appendChild(icon)
|
||||
|
||||
const title = document.createElement('strong')
|
||||
// textContent, nicht innerHTML: Label und Notiz sind Nutzereingaben.
|
||||
title.textContent = data.label
|
||||
head.appendChild(title)
|
||||
|
||||
if (data.resolved) {
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'trail-popup-badge'
|
||||
badge.textContent = 'erledigt'
|
||||
head.appendChild(badge)
|
||||
}
|
||||
|
||||
root.appendChild(head)
|
||||
|
||||
if (data.note) {
|
||||
const note = document.createElement('p')
|
||||
note.className = 'trail-popup-note'
|
||||
note.textContent = data.note
|
||||
root.appendChild(note)
|
||||
}
|
||||
|
||||
const meta = document.createElement('p')
|
||||
meta.className = 'trail-popup-meta'
|
||||
meta.textContent = [data.when, data.who].filter(Boolean).join(' · ')
|
||||
if (meta.textContent) root.appendChild(meta)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// Marker neu aufbauen — bei überschaubaren Mengen einfacher und
|
||||
// zuverlässiger als einzelne Aktualisierungen.
|
||||
$effect(() => {
|
||||
if (!map || !ready) return
|
||||
const instance = map
|
||||
|
||||
for (const m of markerObjects) m.remove()
|
||||
markerObjects = []
|
||||
clearIcons()
|
||||
|
||||
for (const data of markers) {
|
||||
const el = document.createElement('button')
|
||||
|
|
@ -198,15 +365,28 @@
|
|||
el.title = data.label
|
||||
el.setAttribute('aria-label', data.label)
|
||||
el.style.cssText = `
|
||||
width: 18px; height: 18px; border-radius: 9999px;
|
||||
width: 26px; height: 26px; border-radius: 9999px;
|
||||
border: 2px solid white; cursor: pointer; padding: 0;
|
||||
display: grid; place-items: center; color: white;
|
||||
background: ${data.color};
|
||||
opacity: ${data.resolved ? '0.35' : '1'};
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.4);
|
||||
`
|
||||
mountIcon(el, data.icon, 'size-3.5')
|
||||
// Der Klick bleibt beim Marker: Er darf kein neues Marker-Fenster
|
||||
// öffnen, sondern zeigt nur, was hier schon gemeldet wurde.
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
onmarkerclick?.(data.id)
|
||||
|
||||
popup?.remove()
|
||||
popup = new maplibregl.Popup({
|
||||
offset: 14,
|
||||
closeButton: false,
|
||||
className: 'trail-popup-shell',
|
||||
})
|
||||
.setLngLat([data.lng, data.lat])
|
||||
.setDOMContent(popupContent(data))
|
||||
.addTo(instance)
|
||||
})
|
||||
el.addEventListener('focus', () => {
|
||||
el.style.outline = '2px solid #0ea5e9'
|
||||
|
|
@ -219,7 +399,7 @@
|
|||
markerObjects.push(
|
||||
new maplibregl.Marker({ element: el })
|
||||
.setLngLat([data.lng, data.lat])
|
||||
.addTo(map),
|
||||
.addTo(instance),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -234,20 +414,11 @@
|
|||
highlightMarker = null
|
||||
|
||||
const coords = geojson?.coordinates
|
||||
if (highlightDistance == null || !coords?.length || !coordDistances.length) return
|
||||
if (highlightDistance == null || !coords?.length) return
|
||||
|
||||
// Nächstgelegene Koordinate zur gesuchten Distanz
|
||||
let best = 0
|
||||
let bestDiff = Infinity
|
||||
for (let i = 0; i < coordDistances.length; i++) {
|
||||
const diff = Math.abs(coordDistances[i] - highlightDistance)
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff
|
||||
best = i
|
||||
}
|
||||
}
|
||||
const at = positionAtDistance(coords, coordDistances, highlightDistance)
|
||||
if (!at) return
|
||||
|
||||
const idx = Math.min(coords.length - 1, best)
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = `
|
||||
width: 12px; height: 12px; border-radius: 9999px;
|
||||
|
|
@ -255,9 +426,7 @@
|
|||
box-shadow: 0 1px 4px rgba(0,0,0,.4);
|
||||
`
|
||||
|
||||
highlightMarker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat(coords[idx])
|
||||
.addTo(map)
|
||||
highlightMarker = new maplibregl.Marker({ element: el }).setLngLat(at).addTo(map)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -265,11 +434,52 @@
|
|||
bind:this={container}
|
||||
style:height
|
||||
class="w-full rounded-lg overflow-hidden border"
|
||||
class:cursor-crosshair={placing}
|
||||
></div>
|
||||
|
||||
{#if placing}
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
Klicke auf die Karte, um die Stelle zu markieren.
|
||||
</p>
|
||||
{/if}
|
||||
<!--
|
||||
Popups hängt maplibre selbst in den Karten-Container, deshalb greifen die
|
||||
Regeln nur global. Bewusst helle Fläche wie das maplibre-Standardpopup —
|
||||
im Dunkelmodus liegt es trotzdem lesbar über der hellen Karte.
|
||||
-->
|
||||
<style>
|
||||
:global(.trail-popup-shell .maplibregl-popup-content) {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
|
||||
font-family: inherit;
|
||||
max-width: 15rem;
|
||||
}
|
||||
|
||||
:global(.trail-popup-head) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.875rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
:global(.trail-popup-icon) {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.trail-popup-badge) {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 9999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
:global(.trail-popup-note) {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
:global(.trail-popup-meta) {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<thead
|
||||
bind:this={ref}
|
||||
data-slot="table-header"
|
||||
class={cn("[&_tr]:border-b", className)}
|
||||
class={cn("[&_tr]:border-b [&_tr]:hover:bg-transparent", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
|
|
|||
49
frontend/src/lib/flagIcons.ts
Normal file
49
frontend/src/lib/flagIcons.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Icons für Flag-Typen.
|
||||
*
|
||||
* Erlaubt ist jeder Name aus lucide.dev — die feste Liste unten sind nur
|
||||
* Vorschläge fürs schnelle Klicken. Möglich macht das ein Glob über die
|
||||
* Icon-Dateien des Pakets: Jedes Icon wird ein eigener Chunk und erst geladen,
|
||||
* wenn es wirklich gebraucht wird. Ein statischer Import des ganzen Pakets
|
||||
* hätte mehrere hundert Kilobyte für eine Handvoll Symbole gekostet, ein
|
||||
* dynamischer Import mit Variable ließe sich vom Bundler nicht auflösen.
|
||||
*/
|
||||
|
||||
type IconModule = { default: unknown }
|
||||
|
||||
const modules = import.meta.glob('/node_modules/lucide-svelte/dist/icons/*.svelte') as Record<
|
||||
string,
|
||||
() => Promise<IconModule>
|
||||
>
|
||||
|
||||
/** `TreePine`, `tree-pine` und `treePine` führen zum selben Icon. */
|
||||
export function toKebab(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** Lader für einen Icon-Namen; null, wenn es das Icon nicht gibt. */
|
||||
export function iconLoader(name: string | undefined): (() => Promise<IconModule>) | null {
|
||||
if (!name?.trim()) return null
|
||||
return modules[`/node_modules/lucide-svelte/dist/icons/${toKebab(name)}.svelte`] ?? null
|
||||
}
|
||||
|
||||
/** Ob ein eingegebener Name existiert — für die Rückmeldung im Dialog. */
|
||||
export function iconExists(name: string | undefined): boolean {
|
||||
return iconLoader(name) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschläge für die Auswahl im Dialog. Bewusst nach Themen sortiert und
|
||||
* nicht abschließend: Wer etwas anderes braucht, tippt den Namen ein.
|
||||
*/
|
||||
export const FLAG_ICON_SUGGESTIONS = [
|
||||
'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info',
|
||||
'TriangleAlert', 'Mountain', 'Droplets', 'CloudRain', 'Snowflake', 'Flame',
|
||||
'Zap', 'Bike', 'Wrench', 'Hammer', 'Shovel', 'Route',
|
||||
'Milestone', 'Signpost', 'Footprints', 'Fence', 'Lock', 'CircleParking',
|
||||
'CameraOff', 'Skull', 'Bug', 'Leaf', 'Trash2', 'Flag',
|
||||
]
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { parseGpx, haversine, simplify } from './gpx'
|
||||
import { parseGpx, haversine, simplify, nearestOnLine, distanceAlong, positionAtDistance } from './gpx'
|
||||
|
||||
// Zwei Punkte in Hersbruck, rund 1,4 km auseinander.
|
||||
const SAMPLE = `<?xml version="1.0"?>
|
||||
|
|
@ -189,3 +189,103 @@ describe('coordDistances', () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('nearestOnLine', () => {
|
||||
// Zwei Segmente entlang eines Breitengrads, dann nach Norden.
|
||||
const LINE: [number, number][] = [
|
||||
[11.43, 49.51],
|
||||
[11.44, 49.51],
|
||||
[11.44, 49.52],
|
||||
]
|
||||
|
||||
it('projiziert einen Punkt neben der Linie auf das nächste Segment', () => {
|
||||
const pos = nearestOnLine(LINE, 11.435, 49.5105)!
|
||||
|
||||
expect(pos.index).toBe(0)
|
||||
expect(pos.lat).toBeCloseTo(49.51, 6)
|
||||
expect(pos.lng).toBeCloseTo(11.435, 6)
|
||||
expect(pos.t).toBeCloseTo(0.5, 3)
|
||||
})
|
||||
|
||||
it('bleibt an den Enden auf der Linie', () => {
|
||||
const before = nearestOnLine(LINE, 11.42, 49.51)!
|
||||
expect(before.lng).toBeCloseTo(11.43, 6)
|
||||
expect(before.t).toBe(0)
|
||||
|
||||
const after = nearestOnLine(LINE, 11.44, 49.53)!
|
||||
expect(after.index).toBe(1)
|
||||
expect(after.lat).toBeCloseTo(49.52, 6)
|
||||
expect(after.t).toBe(1)
|
||||
})
|
||||
|
||||
it('wählt das wirklich nächste Segment, nicht das erste passende', () => {
|
||||
const pos = nearestOnLine(LINE, 11.4405, 49.5195)!
|
||||
expect(pos.index).toBe(1)
|
||||
})
|
||||
|
||||
it('liefert null ohne Koordinaten', () => {
|
||||
expect(nearestOnLine([], 11.43, 49.51)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('distanceAlong', () => {
|
||||
const DISTANCES = [0, 1000, 1500]
|
||||
|
||||
it('interpoliert innerhalb eines Segments', () => {
|
||||
expect(distanceAlong({ lng: 0, lat: 0, index: 0, t: 0.25 }, DISTANCES)).toBe(250)
|
||||
expect(distanceAlong({ lng: 0, lat: 0, index: 1, t: 0.5 }, DISTANCES)).toBe(1250)
|
||||
})
|
||||
|
||||
it('liefert den letzten Wert am Ende der Linie', () => {
|
||||
expect(distanceAlong({ lng: 0, lat: 0, index: 2, t: 0 }, DISTANCES)).toBe(1500)
|
||||
})
|
||||
|
||||
it('liefert null ohne Distanztabelle', () => {
|
||||
expect(distanceAlong({ lng: 0, lat: 0, index: 0, t: 0.5 }, [])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('positionAtDistance', () => {
|
||||
const LINE: [number, number][] = [
|
||||
[11.43, 49.51],
|
||||
[11.44, 49.51],
|
||||
[11.44, 49.52],
|
||||
]
|
||||
const DISTANCES = [0, 1000, 2000]
|
||||
|
||||
it('findet die Koordinate zu einem Streckenkilometer', () => {
|
||||
const at = positionAtDistance(LINE, DISTANCES, 500)!
|
||||
expect(at[0]).toBeCloseTo(11.435, 6)
|
||||
expect(at[1]).toBeCloseTo(49.51, 6)
|
||||
})
|
||||
|
||||
it('interpoliert auch im zweiten Segment', () => {
|
||||
const at = positionAtDistance(LINE, DISTANCES, 1750)!
|
||||
expect(at[0]).toBeCloseTo(11.44, 6)
|
||||
expect(at[1]).toBeCloseTo(49.5175, 6)
|
||||
})
|
||||
|
||||
it('klemmt Werte außerhalb der Strecke auf Anfang und Ende', () => {
|
||||
expect(positionAtDistance(LINE, DISTANCES, -100)).toEqual(LINE[0])
|
||||
expect(positionAtDistance(LINE, DISTANCES, 99999)).toEqual(LINE[2])
|
||||
})
|
||||
|
||||
it('liefert null ohne Distanztabelle', () => {
|
||||
expect(positionAtDistance(LINE, [], 500)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zusammenspiel mit parseGpx', () => {
|
||||
it('führt Projektion und Rückweg auf dieselbe Stelle', () => {
|
||||
const parsed = parseGpx(SAMPLE)
|
||||
const coords = parsed.geojson.coordinates
|
||||
|
||||
// Ein Punkt leicht neben der Mitte des zweiten Segments
|
||||
const pos = nearestOnLine(coords, coords[1][0] + 0.0002, coords[1][1])!
|
||||
const d = distanceAlong(pos, parsed.coordDistances)!
|
||||
const back = positionAtDistance(coords, parsed.coordDistances, d)!
|
||||
|
||||
expect(back[0]).toBeCloseTo(pos.lng, 4)
|
||||
expect(back[1]).toBeCloseTo(pos.lat, 4)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -222,3 +222,112 @@ export function parseGpx(xml: string): ParsedGpx {
|
|||
ascent_m: Math.round(ascent),
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein auf die Trail-Linie projizierter Punkt. */
|
||||
export type LinePosition = {
|
||||
lng: number
|
||||
lat: number
|
||||
/** Index des Segmentanfangs in `coordinates` */
|
||||
index: number
|
||||
/** Lage im Segment zwischen 0 und 1 */
|
||||
t: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Projiziert einen beliebigen Punkt auf die nächstgelegene Stelle der Linie.
|
||||
* Damit landet ein Marker exakt auf dem Track, egal wie genau geklickt wurde.
|
||||
*
|
||||
* Gerechnet wird in Grad, die Längengrade aber mit cos(lat) gestaucht — sonst
|
||||
* wäre ein Grad Ost-West bei uns fast doppelt so „lang" wie einer Nord-Süd und
|
||||
* die Projektion würde spürbar daneben liegen. Für die paar Meter Suchradius
|
||||
* genügt das; eine echte Projektion wäre hier Aufwand ohne Gewinn.
|
||||
*/
|
||||
export function nearestOnLine(
|
||||
coords: [number, number][],
|
||||
lng: number,
|
||||
lat: number,
|
||||
): LinePosition | null {
|
||||
if (!coords.length) return null
|
||||
if (coords.length === 1) {
|
||||
return { lng: coords[0][0], lat: coords[0][1], index: 0, t: 0 }
|
||||
}
|
||||
|
||||
const kx = Math.cos((lat * Math.PI) / 180) || 1
|
||||
|
||||
let best: LinePosition | null = null
|
||||
let bestDist = Infinity
|
||||
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const [ax, ay] = coords[i]
|
||||
const [bx, by] = coords[i + 1]
|
||||
|
||||
const dx = (bx - ax) * kx
|
||||
const dy = by - ay
|
||||
const px = (lng - ax) * kx
|
||||
const py = lat - ay
|
||||
|
||||
const len = dx * dx + dy * dy
|
||||
// Doppelter Punkt im Track: das Segment hat keine Richtung.
|
||||
const t = len === 0 ? 0 : Math.max(0, Math.min(1, (px * dx + py * dy) / len))
|
||||
|
||||
const ox = px - t * dx
|
||||
const oy = py - t * dy
|
||||
const dist = ox * ox + oy * oy
|
||||
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist
|
||||
best = { lng: ax + (bx - ax) * t, lat: ay + (by - ay) * t, index: i, t }
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Streckenkilometer einer projizierten Stelle. `coordDistances` hat einen
|
||||
* Eintrag je Koordinate; zwischen zwei Stützpunkten wird linear interpoliert.
|
||||
* Ohne Distanztabelle (Versionen von vor dem Feld) gibt es kein Ergebnis.
|
||||
*/
|
||||
export function distanceAlong(pos: LinePosition, coordDistances: number[]): number | null {
|
||||
if (coordDistances.length <= pos.index) return null
|
||||
|
||||
const a = coordDistances[pos.index]
|
||||
const b = coordDistances[pos.index + 1]
|
||||
if (b == null) return a
|
||||
|
||||
return a + (b - a) * pos.t
|
||||
}
|
||||
|
||||
/**
|
||||
* Gegenrichtung zu `distanceAlong`: die Koordinate zu einem Streckenkilometer.
|
||||
* Ebenfalls interpoliert, damit ein Punkt beim Überfahren gleichmäßig wandert
|
||||
* statt von Stützpunkt zu Stützpunkt zu springen.
|
||||
*/
|
||||
export function positionAtDistance(
|
||||
coords: [number, number][],
|
||||
coordDistances: number[],
|
||||
distance: number,
|
||||
): [number, number] | null {
|
||||
if (!coords.length || coordDistances.length < 2) return null
|
||||
|
||||
const last = Math.min(coords.length, coordDistances.length) - 1
|
||||
if (distance <= coordDistances[0]) return coords[0]
|
||||
if (distance >= coordDistances[last]) return coords[last]
|
||||
|
||||
// Binäre Suche nach dem Segment, in dem die Distanz liegt.
|
||||
let lo = 0
|
||||
let hi = last
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (coordDistances[mid] <= distance) lo = mid
|
||||
else hi = mid
|
||||
}
|
||||
|
||||
const span = coordDistances[hi] - coordDistances[lo]
|
||||
const t = span === 0 ? 0 : (distance - coordDistances[lo]) / span
|
||||
|
||||
return [
|
||||
coords[lo][0] + (coords[hi][0] - coords[lo][0]) * t,
|
||||
coords[lo][1] + (coords[hi][1] - coords[lo][1]) * t,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
|
|||
import { api } from './pocketbase.svelte'
|
||||
import { app } from './app.svelte'
|
||||
import { getTeamContext } from './teams.svelte'
|
||||
import { dayOf, todayISO } from '$lib/time'
|
||||
import type { EventsResponse } from '$lib/types'
|
||||
|
||||
const KEY = Symbol('event')
|
||||
|
|
@ -87,14 +88,56 @@ export class EventStore {
|
|||
return this.records.find((r) => r.id === id)
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this.scoped.filter((r) => r.status === 'active')
|
||||
/**
|
||||
* Nach Datum sortiert, das nächste zuerst. Events ohne Datum hängen
|
||||
* hinten dran — sie sind noch nicht terminiert.
|
||||
*/
|
||||
get byDate(): EventsResponse[] {
|
||||
return [...this.scoped].sort((a, b) => {
|
||||
const da = dayOf(a.starts)
|
||||
const db = dayOf(b.starts)
|
||||
if (!da && !db) return 0
|
||||
if (!da) return 1
|
||||
if (!db) return -1
|
||||
return da < db ? -1 : da > db ? 1 : 0
|
||||
})
|
||||
}
|
||||
get drafts() {
|
||||
return this.scoped.filter((r) => r.status === 'draft')
|
||||
|
||||
/**
|
||||
* Läuft heute. Ohne Enddatum zählt der Starttag allein — genau dann ist
|
||||
* ein eintägiges Event „heute".
|
||||
*/
|
||||
get active(): EventsResponse[] {
|
||||
const today = todayISO()
|
||||
return this.byDate.filter((r) => {
|
||||
const from = dayOf(r.starts)
|
||||
if (!from) return false
|
||||
const to = dayOf(r.ends) || from
|
||||
return from <= today && today <= to
|
||||
})
|
||||
}
|
||||
get finished() {
|
||||
return this.scoped.filter((r) => r.status === 'finished')
|
||||
|
||||
/** Steht noch an. */
|
||||
get upcoming(): EventsResponse[] {
|
||||
const today = todayISO()
|
||||
return this.byDate.filter((r) => dayOf(r.starts) > today)
|
||||
}
|
||||
|
||||
/** Vorbei — der letzte Tag liegt hinter uns. */
|
||||
get past(): EventsResponse[] {
|
||||
const today = todayISO()
|
||||
return this.byDate
|
||||
.filter((r) => {
|
||||
const from = dayOf(r.starts)
|
||||
if (!from) return false
|
||||
return (dayOf(r.ends) || from) < today
|
||||
})
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/** Noch ohne Termin. */
|
||||
get undated(): EventsResponse[] {
|
||||
return this.byDate.filter((r) => !dayOf(r.starts))
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { getContext, setContext } from 'svelte'
|
|||
import { api } from './pocketbase.svelte'
|
||||
import { app } from './app.svelte'
|
||||
import { getTeamContext } from './teams.svelte'
|
||||
import type { RidersResponse } from '$lib/types'
|
||||
import type { RidersResponse, UsersResponse } from '$lib/types'
|
||||
|
||||
const KEY = Symbol('rider')
|
||||
|
||||
|
|
@ -34,6 +34,15 @@ export class RiderStore {
|
|||
return this.records.filter((r) => r.team === teamId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Anlegen, ändern und löschen darf nur die Teamleitung — dieselbe Regel
|
||||
* wie in createRule/updateRule/deleteRule der Collection. Die Prüfung
|
||||
* hier blendet nur UI aus; durchgesetzt wird sie serverseitig.
|
||||
*/
|
||||
get canManage(): boolean {
|
||||
return this.teams.isAdmin()
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
|
@ -102,6 +111,34 @@ export class RiderStore {
|
|||
return this.records.find((r) => r.id === id)
|
||||
}
|
||||
|
||||
/** Fahrer, der zu einem Login gehört — für „das bin ich"-Ansichten. */
|
||||
getByUser(userId: string) {
|
||||
return this.scoped.filter((r) => r.user === userId)
|
||||
}
|
||||
|
||||
/** Bestehendes Konto zuordnen oder die Zuordnung lösen. */
|
||||
async linkUser(riderId: string, userId: string | null) {
|
||||
return await this.edit(riderId, { user: userId ?? '' } as Partial<RidersResponse>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt dem Fahrer ein Login an und verknüpft es. Das Konto entsteht im
|
||||
* Team-Store — es ist zuerst ein Teammitglied und erst dann dieser Fahrer.
|
||||
*/
|
||||
async createLogin(
|
||||
rider: RidersResponse,
|
||||
data: { email: string; password: string; name?: string },
|
||||
): Promise<UsersResponse> {
|
||||
const user = await this.teams.createMember({
|
||||
...data,
|
||||
name: data.name?.trim() || fullName(rider),
|
||||
})
|
||||
|
||||
await this.linkUser(rider.id, user.id)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
getByEvent(eventId: string) {
|
||||
return this.scoped.filter((r) => r.event === eventId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ export class TeamStore {
|
|||
loading = $state(false)
|
||||
error = $state<string | null>(null)
|
||||
|
||||
/**
|
||||
* Aufgelöste User-Datensätze, nach ID. Ein Team kennt nur die IDs seiner
|
||||
* Mitglieder; die Namen holt sich die Oberfläche einzeln nach. Cache,
|
||||
* damit dieselbe ID nicht auf jeder Seite erneut geladen wird.
|
||||
*/
|
||||
users = $state<Record<string, UsersResponse>>({})
|
||||
|
||||
private unsubscribe: (() => void) | null = null
|
||||
|
||||
get active(): TeamsResponse | null {
|
||||
|
|
@ -168,6 +175,63 @@ export class TeamStore {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen User nachladen. Sichtbar ist ein fremder Datensatz nur über seine
|
||||
* ID (users.viewRule); durchblättern lässt sich die Collection nicht.
|
||||
*/
|
||||
async loadUser(id: string): Promise<UsersResponse | null> {
|
||||
if (this.users[id]) return this.users[id]
|
||||
|
||||
try {
|
||||
const u = await api.collection('users').getOne(id, { requestKey: null })
|
||||
this.users = { ...this.users, [id]: u }
|
||||
return u
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle Mitglieder eines Teams in den Cache holen. */
|
||||
async loadMembers(team: TeamsResponse | null = this.active) {
|
||||
if (!team) return
|
||||
const ids = [...(team.users ?? []), team.owner].filter(Boolean) as string[]
|
||||
await Promise.all(ids.filter((id) => !this.users[id]).map((id) => this.loadUser(id)))
|
||||
}
|
||||
|
||||
/** Anzeigename zu einer User-ID; fällt auf eine gekürzte ID zurück. */
|
||||
userLabel(id: string): string {
|
||||
const u = this.users[id]
|
||||
if (!u) return id.slice(0, 6) + '…'
|
||||
return u.name || u.email || id.slice(0, 6) + '…'
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die
|
||||
* REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das
|
||||
* Konto bereits — der Aufrufer meldet den Fehler, statt still
|
||||
* weiterzumachen.
|
||||
*
|
||||
* Erlaubt ist das seit 1754500500_riders_user_and_admin_rules.js jedem
|
||||
* eingeloggten Nutzer (users.createRule); eine öffentliche Registrierung
|
||||
* gibt es weiterhin nicht.
|
||||
*/
|
||||
async createMember(data: { email: string; password: string; name?: string }, teamId = this.activeId) {
|
||||
if (!teamId) throw new Error('Kein aktives Team')
|
||||
|
||||
const user = (await api.collection('users').create({
|
||||
email: data.email.trim(),
|
||||
emailVisibility: false,
|
||||
password: data.password,
|
||||
passwordConfirm: data.password,
|
||||
name: data.name?.trim() || data.email.trim(),
|
||||
})) as UsersResponse
|
||||
|
||||
this.users = { ...this.users, [user.id]: user }
|
||||
await this.addMember(teamId, user.id)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
/** User per E-Mail suchen (für Einladungs-UI) */
|
||||
async findUserByEmail(email: string): Promise<UsersResponse | null> {
|
||||
try {
|
||||
|
|
|
|||
22
frontend/src/lib/stores/ticker.svelte.ts
Normal file
22
frontend/src/lib/stores/ticker.svelte.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Laufende Uhr für Live-Anzeigen. Wird im Komponenten-Setup aufgerufen; das
|
||||
* Intervall hängt am Effekt-Kontext der Komponente und endet mit ihr.
|
||||
*
|
||||
* Der Wert wird bewusst als Zahl herausgereicht statt als fertiger Text:
|
||||
* Ableitungen, die `now` lesen, rechnen dadurch bei jedem Tick neu — ohne
|
||||
* `{#key}`-Blöcke, die sonst ganze Listen neu aufbauen würden.
|
||||
*/
|
||||
export function ticker(intervalMs = 100) {
|
||||
let now = $state(Date.now())
|
||||
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (now = Date.now()), intervalMs)
|
||||
return () => clearInterval(id)
|
||||
})
|
||||
|
||||
return {
|
||||
get now() {
|
||||
return now
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
|
|||
import { api } from './pocketbase.svelte'
|
||||
import { app } from './app.svelte'
|
||||
import { getTeamContext } from './teams.svelte'
|
||||
import { formatDuration } from '$lib/time'
|
||||
import type { TimesResponse, TimesStatusOptions } from '$lib/types'
|
||||
|
||||
const KEY = Symbol('time')
|
||||
|
|
@ -13,18 +14,22 @@ export interface TimeWithDuration extends TimesResponse {
|
|||
formattedTime?: string
|
||||
}
|
||||
|
||||
function enrich(t: TimesResponse): TimeWithDuration {
|
||||
/**
|
||||
* `now` steckt als Parameter drin, damit laufende Zeiten live weiterzählen:
|
||||
* Eine Ableitung, die einen tickenden Zeitwert hereinreicht, rechnet bei
|
||||
* jedem Tick neu. Ohne den Parameter wäre `Date.now()` für Svelte keine
|
||||
* Abhängigkeit und die Anzeige stünde still.
|
||||
*/
|
||||
function enrich(t: TimesResponse, now: number): TimeWithDuration {
|
||||
if (!t.start) return t
|
||||
const start = new Date(t.start).getTime()
|
||||
const end = t.stop ? new Date(t.stop).getTime() : Date.now()
|
||||
const end = t.stop ? new Date(t.stop).getTime() : now
|
||||
let duration = (end - start) / 1000
|
||||
if (t.correction) duration += t.correction
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = (duration % 60).toFixed(1)
|
||||
return {
|
||||
...t,
|
||||
duration,
|
||||
formattedTime: `${minutes}:${seconds.padStart(4, '0')}`,
|
||||
formattedTime: formatDuration(duration),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,12 +128,29 @@ export class TimeStore {
|
|||
})
|
||||
}
|
||||
|
||||
getByRun(runId: string): TimeWithDuration[] {
|
||||
return this.scoped.filter((r) => r.run === runId).map(enrich)
|
||||
getByRun(runId: string, now = Date.now()): TimeWithDuration[] {
|
||||
return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now))
|
||||
}
|
||||
|
||||
getByRider(riderId: string): TimeWithDuration[] {
|
||||
return this.scoped.filter((r) => r.rider === riderId).map(enrich)
|
||||
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
|
||||
return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now))
|
||||
}
|
||||
|
||||
/** Laufende Zeiten eines Runs — der am längsten laufende zuerst. */
|
||||
runningByRun(runId: string, now = Date.now()): TimeWithDuration[] {
|
||||
return this.getByRun(runId, now)
|
||||
.filter((r) => r.status === 'active')
|
||||
.sort((a, b) => (a.start < b.start ? -1 : 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf,
|
||||
* dürfen aber auch nicht unsichtbar werden.
|
||||
*/
|
||||
unrankedByRun(runId: string): TimeWithDuration[] {
|
||||
return this.getByRun(runId).filter(
|
||||
(r) => r.status !== 'active' && r.status !== 'finished',
|
||||
)
|
||||
}
|
||||
|
||||
isRiderActive(runId: string, riderId: string) {
|
||||
|
|
@ -144,11 +166,23 @@ export class TimeStore {
|
|||
}
|
||||
|
||||
get running(): TimeWithDuration[] {
|
||||
return this.scoped.filter((r) => r.status === 'active').map(enrich)
|
||||
return this.allRunning()
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle laufenden Zeiten des Teams, run-übergreifend — der am längsten
|
||||
* laufende zuerst. `now` wie bei `getByRun`, damit die Anzeige mitzählt.
|
||||
*/
|
||||
allRunning(now = Date.now()): TimeWithDuration[] {
|
||||
return this.scoped
|
||||
.filter((r) => r.status === 'active')
|
||||
.map((r) => enrich(r, now))
|
||||
.sort((a, b) => (a.start < b.start ? -1 : 1))
|
||||
}
|
||||
|
||||
get finished(): TimeWithDuration[] {
|
||||
return this.scoped.filter((r) => r.status === 'finished').map(enrich)
|
||||
const now = Date.now()
|
||||
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
|
||||
}
|
||||
|
||||
getLeaderboard(runId: string): TimeWithDuration[] {
|
||||
|
|
@ -157,6 +191,12 @@ export class TimeStore {
|
|||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
|
||||
}
|
||||
|
||||
/** Platz eines Fahrers im Run, 1-basiert; null ohne gewertete Zeit. */
|
||||
placement(runId: string, riderId: string): number | null {
|
||||
const idx = this.getLeaderboard(runId).findIndex((t) => t.rider === riderId)
|
||||
return idx === -1 ? null : idx + 1
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe()
|
||||
|
|
|
|||
|
|
@ -105,14 +105,19 @@ export class TrailFlagStore {
|
|||
* unangetastet — der Knopf lässt sich also gefahrlos mehrfach drücken.
|
||||
* Gibt die Anzahl neu angelegter Flags zurück.
|
||||
*/
|
||||
async seedDefaults(): Promise<number> {
|
||||
const teamId = this.teams.activeId
|
||||
async seedDefaults(teamId = this.teams.activeId): Promise<number> {
|
||||
if (!teamId) throw new Error('Kein aktives Team')
|
||||
if (!this.canManage) {
|
||||
// Beim Anlegen eines Teams ist man dessen Owner, aber der Datensatz
|
||||
// steckt womöglich noch nicht im Store — dann greift die Prüfung ins
|
||||
// Leere. Für das eigene, gerade erzeugte Team wird sie übersprungen;
|
||||
// die createRule der Collection setzt sie ohnehin serverseitig durch.
|
||||
if (teamId === this.teams.activeId && !this.canManage) {
|
||||
throw new Error('Dafür brauchst du Administratorrechte im Team.')
|
||||
}
|
||||
|
||||
const existing = new Set(this.scoped.map((f) => f.label))
|
||||
const existing = new Set(
|
||||
this.records.filter((f) => f.team === teamId).map((f) => f.label),
|
||||
)
|
||||
let created = 0
|
||||
|
||||
for (const flag of DEFAULT_FLAGS) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { getContext, setContext } from 'svelte'
|
||||
import { api, auth } from './pocketbase.svelte'
|
||||
import { getTeamContext } from './teams.svelte'
|
||||
import type { TrailMarkersResponse } from '$lib/types'
|
||||
import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
|
||||
|
||||
const KEY = Symbol('trailMarker')
|
||||
|
||||
/**
|
||||
* Marker samt aufgelöstem Melder. Ob `expand.created_by` gefüllt ist, hängt
|
||||
* an den Sichtbarkeitsregeln der users-Collection — deshalb optional.
|
||||
*/
|
||||
export type TrailMarker = Omit<TrailMarkersResponse, 'expand'> & {
|
||||
expand?: { created_by?: UsersResponse }
|
||||
}
|
||||
|
||||
export class TrailMarkerStore {
|
||||
records = $state<TrailMarkersResponse[]>([])
|
||||
records = $state<TrailMarker[]>([])
|
||||
loading = $state(false)
|
||||
error = $state<string | null>(null)
|
||||
|
||||
|
|
@ -14,14 +22,14 @@ export class TrailMarkerStore {
|
|||
private teams = getTeamContext()
|
||||
|
||||
/** Alle Marker eines Trails, jüngste zuerst */
|
||||
byTrail(trailId: string): TrailMarkersResponse[] {
|
||||
byTrail(trailId: string): TrailMarker[] {
|
||||
return this.records
|
||||
.filter((r) => r.trail === trailId)
|
||||
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
||||
}
|
||||
|
||||
/** Nur die offenen — was auf der Karte auffallen soll */
|
||||
openByTrail(trailId: string): TrailMarkersResponse[] {
|
||||
openByTrail(trailId: string): TrailMarker[] {
|
||||
return this.byTrail(trailId).filter((r) => !r.resolved)
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +38,7 @@ export class TrailMarkerStore {
|
|||
* Team-Admins — dieselbe Regel wie in der updateRule der Collection.
|
||||
* Der Trail wird übergeben, weil der Store ihn nicht kennt.
|
||||
*/
|
||||
canEdit(marker: TrailMarkersResponse, stewards: string[], teamOwner?: string, teamAdmins: string[] = []): boolean {
|
||||
canEdit(marker: TrailMarker, stewards: string[], teamOwner?: string, teamAdmins: string[] = []): boolean {
|
||||
const uid = auth.user?.id
|
||||
if (!uid) return false
|
||||
|
||||
|
|
@ -42,12 +50,25 @@ export class TrailMarkerStore {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer den Marker gemeldet hat. Fremde User-Datensätze sind derzeit nicht
|
||||
* sichtbar (users.listRule: `id = @request.auth.id`), deshalb bleibt der
|
||||
* Name bei Meldungen anderer offen.
|
||||
*/
|
||||
authorName(marker: TrailMarker): string {
|
||||
const name = marker.expand?.created_by?.name
|
||||
if (name) return name
|
||||
if (marker.created_by && marker.created_by === auth.user?.id) return 'Du'
|
||||
return 'Unbekannt'
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
this.records = await api.collection('trail_markers').getFullList({
|
||||
sort: '-created',
|
||||
expand: 'created_by',
|
||||
requestKey: null,
|
||||
})
|
||||
} catch (e: any) {
|
||||
|
|
@ -61,16 +82,31 @@ export class TrailMarkerStore {
|
|||
subscribe() {
|
||||
if (this.unsubscribe) return
|
||||
api.collection('trail_markers').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
|
||||
// Realtime liefert den Datensatz ungetypt; angefordert wird unten
|
||||
// dasselbe expand wie beim Laden.
|
||||
const record = e.record as TrailMarker
|
||||
const idx = this.records.findIndex((r) => r.id === record.id)
|
||||
if (e.action === 'create' && idx === -1) this.records = [record, ...this.records]
|
||||
else if (e.action === 'update' && idx !== -1) this.records[idx] = record
|
||||
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
|
||||
}).then((unsub) => {
|
||||
}, { expand: 'created_by' }).then((unsub) => {
|
||||
this.unsubscribe = unsub
|
||||
}).catch((e) => console.warn('trail_markers subscribe failed:', e))
|
||||
}
|
||||
|
||||
async create(data: { trail: string; flag: string; lat: number; lng: number; note?: string }) {
|
||||
/**
|
||||
* `flag` ist optional: Ein Marker darf mit eigenem `label` und `icon`
|
||||
* auskommen, wenn kein vorgefertigter Typ passt.
|
||||
*/
|
||||
async create(data: {
|
||||
trail: string
|
||||
flag?: string
|
||||
label?: string
|
||||
icon?: string
|
||||
lat: number
|
||||
lng: number
|
||||
note?: string
|
||||
}) {
|
||||
const teamId = this.teams.activeId
|
||||
if (!teamId) throw new Error('Kein aktives Team')
|
||||
|
||||
|
|
@ -82,7 +118,7 @@ export class TrailMarkerStore {
|
|||
})
|
||||
}
|
||||
|
||||
async edit(id: string, data: Partial<TrailMarkersResponse>) {
|
||||
async edit(id: string, data: Partial<TrailMarker>) {
|
||||
return await api.collection('trail_markers').update(id, data)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,18 @@ export class TrailStore {
|
|||
return await api.collection('trails').update(id, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Status melden. Wann und von wem kommt gleich mit — ohne das ließe sich
|
||||
* einer Sperrung nicht ansehen, ob sie von heute oder vom letzten Jahr ist.
|
||||
*/
|
||||
async setStatus(id: string, status: string) {
|
||||
return await api.collection('trails').update(id, {
|
||||
status,
|
||||
status_changed: new Date().toISOString(),
|
||||
status_by: auth.user?.id ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
async setStewards(id: string, userIds: string[]) {
|
||||
return await api.collection('trails').update(id, { stewards: userIds })
|
||||
}
|
||||
|
|
|
|||
72
frontend/src/lib/time.ts
Normal file
72
frontend/src/lib/time.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Formatierung von Zeiten. Bewusst hier und nicht im times-Store: Auch
|
||||
* Komponenten ohne Store-Zugriff zeigen Dauern an, und die Darstellung soll
|
||||
* überall dieselbe sein.
|
||||
*/
|
||||
|
||||
/** Dauer in Sekunden als `m:ss.s`. */
|
||||
export function formatDuration(seconds: number | undefined): string {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '0:00.0'
|
||||
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = (seconds % 60).toFixed(1)
|
||||
|
||||
return `${m}:${s.padStart(4, '0')}`
|
||||
}
|
||||
|
||||
/** Beschriftungen der Zeit-Status aus der Collection. */
|
||||
export const TIME_STATUS_LABEL: Record<string, string> = {
|
||||
active: 'Läuft',
|
||||
finished: 'Beendet',
|
||||
dnf: 'DNF',
|
||||
dns: 'DNS',
|
||||
dsq: 'DSQ',
|
||||
}
|
||||
|
||||
/** Zeitpunkt als `TT.MM.JJJJ, HH:MM`. */
|
||||
export function formatDateTime(iso: string | undefined): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
/** Heutiges Datum als `JJJJ-MM-TT` in lokaler Zeit (nicht UTC). */
|
||||
export function todayISO(): string {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Datumsanteil eines PocketBase-Werts. Die kommen als
|
||||
* `2026-09-12 00:00:00.000Z` zurück; für Vergleiche und `<input type="date">`
|
||||
* zählt nur der Tag.
|
||||
*/
|
||||
export function dayOf(value: string | undefined): string {
|
||||
if (!value) return ''
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
/** Ein Tag als `TT.MM.JJJJ`. */
|
||||
export function formatDay(value: string | undefined): string {
|
||||
const day = dayOf(value)
|
||||
if (!day) return ''
|
||||
|
||||
const [y, m, d] = day.split('-')
|
||||
return `${d}.${m}.${y}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitraum eines Events. Ohne Enddatum oder bei gleichem Tag steht nur ein
|
||||
* Datum da; sonst „12.09.2026 – 14.09.2026".
|
||||
*/
|
||||
export function formatDateRange(starts: string | undefined, ends: string | undefined): string {
|
||||
const from = formatDay(starts)
|
||||
const to = formatDay(ends)
|
||||
|
||||
if (!from && !to) return ''
|
||||
if (!from) return to
|
||||
if (!to || to === from) return from
|
||||
|
||||
return `${from} – ${to}`
|
||||
}
|
||||
38
frontend/src/lib/trailStatus.ts
Normal file
38
frontend/src/lib/trailStatus.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Zustände eines Trails samt Farbe und Beschriftung.
|
||||
*
|
||||
* An einer Stelle, weil dieselben Farben in der Kartenlinie, im Abzeichen und
|
||||
* im Auswahlmenü auftauchen — liefen sie auseinander, hieße Grün auf der Karte
|
||||
* etwas anderes als Grün daneben.
|
||||
*/
|
||||
export type TrailStatusValue = 'offen' | 'eingeschraenkt' | 'gesperrt'
|
||||
|
||||
export type TrailStatusInfo = {
|
||||
value: TrailStatusValue
|
||||
label: string
|
||||
/** Als CSS-Farbe, auch für MapLibre-Paint-Eigenschaften verwendbar. */
|
||||
color: string
|
||||
/** Kurzer Hinweis, was der Zustand bedeutet. */
|
||||
hint: string
|
||||
}
|
||||
|
||||
export const TRAIL_STATUS: TrailStatusInfo[] = [
|
||||
{ value: 'offen', label: 'Offen', color: '#16a34a', hint: 'befahrbar' },
|
||||
{
|
||||
value: 'eingeschraenkt',
|
||||
label: 'Eingeschränkt',
|
||||
color: '#ca8a04',
|
||||
hint: 'befahrbar, aber mit Hindernissen',
|
||||
},
|
||||
{ value: 'gesperrt', label: 'Gesperrt', color: '#dc2626', hint: 'nicht befahren' },
|
||||
]
|
||||
|
||||
const BY_VALUE = new Map(TRAIL_STATUS.map((s) => [s.value, s]))
|
||||
|
||||
export function trailStatus(value: string | undefined): TrailStatusInfo {
|
||||
return (value && BY_VALUE.get(value as TrailStatusValue)) || TRAIL_STATUS[0]
|
||||
}
|
||||
|
||||
export function trailStatusColor(value: string | undefined): string {
|
||||
return trailStatus(value).color
|
||||
}
|
||||
16
frontend/src/lib/types.d.ts
vendored
16
frontend/src/lib/types.d.ts
vendored
|
|
@ -46,19 +46,15 @@ export type AuthSystemFields<T = unknown> = {
|
|||
|
||||
// Record types for each collection
|
||||
|
||||
export enum EventsStatusOptions {
|
||||
"draft" = "draft",
|
||||
"active" = "active",
|
||||
"finished" = "finished",
|
||||
}
|
||||
export type EventsRecord = {
|
||||
created?: IsoDateString
|
||||
description?: string
|
||||
ends?: IsoDateString
|
||||
gps?: string
|
||||
id: string
|
||||
location?: string
|
||||
name?: string
|
||||
status?: EventsStatusOptions
|
||||
starts?: IsoDateString
|
||||
team?: RecordIdString
|
||||
updated?: IsoDateString
|
||||
}
|
||||
|
|
@ -74,6 +70,7 @@ export type RidersRecord = {
|
|||
number?: string
|
||||
team?: RecordIdString
|
||||
updated?: IsoDateString
|
||||
user?: RecordIdString
|
||||
}
|
||||
|
||||
export type RunsRecord = {
|
||||
|
|
@ -147,8 +144,10 @@ export type TrailFlagsRecord = {
|
|||
export type TrailMarkersRecord = {
|
||||
created?: IsoDateString
|
||||
created_by?: RecordIdString
|
||||
flag: RecordIdString
|
||||
flag?: RecordIdString
|
||||
icon?: string
|
||||
id: string
|
||||
label?: string
|
||||
lat: number
|
||||
lng: number
|
||||
note?: string
|
||||
|
|
@ -188,6 +187,8 @@ export type TrailsRecord = {
|
|||
id: string
|
||||
name: string
|
||||
status?: TrailsStatusOptions
|
||||
status_by?: RecordIdString
|
||||
status_changed?: IsoDateString
|
||||
stewards?: RecordIdString[]
|
||||
team: RecordIdString
|
||||
updated?: IsoDateString
|
||||
|
|
@ -201,6 +202,7 @@ export type UsersRecord = {
|
|||
id: string
|
||||
name?: string
|
||||
password: string
|
||||
superadmin?: boolean
|
||||
tokenKey: string
|
||||
updated?: IsoDateString
|
||||
username: string
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
import { Separator } from '@/components/ui/separator'
|
||||
import Logo from '$lib/components/Logo.svelte'
|
||||
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
|
||||
import { Home, Calendar, Flag, Timer, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-svelte'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
||||
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte'
|
||||
import { toggleMode, mode } from 'mode-watcher'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
|
|
@ -82,13 +84,14 @@
|
|||
})
|
||||
}
|
||||
|
||||
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
|
||||
// passiert dort, wo es hingehört — am Run innerhalb seines Events.
|
||||
const nav = [
|
||||
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
||||
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
||||
{ href: '/dashboard/riders', icon: Bike, label: 'Fahrer' },
|
||||
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
|
||||
{ href: '/dashboard/timing', icon: Timer, label: 'Zeitnahme' },
|
||||
{ href: '/dashboard/results', icon: Flag, label: 'Ergebnisse' },
|
||||
{ href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' },
|
||||
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
|
@ -96,43 +99,73 @@
|
|||
<div class="min-h-screen bg-background">
|
||||
<header class="border-b bg-card">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Logo />
|
||||
<div class="hidden md:block">
|
||||
<TeamSwitcher />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
|
||||
{#if mode.current === 'dark'}
|
||||
<Sun class="h-4 w-4" />
|
||||
{:else}
|
||||
<Moon class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
|
||||
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
|
||||
voller Groesse wuerde es die 64 px hohe Leiste
|
||||
ueberragen und beschnitten wirken. -->
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Logo class="h-11 w-11 shrink-0" />
|
||||
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
{#each nav as item}
|
||||
{@const Icon = item.icon}
|
||||
<a
|
||||
href={item.href}
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<Icon class="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
{#each nav as item}
|
||||
{@const Icon = item.icon}
|
||||
<a
|
||||
href={item.href}
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<Icon class="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
|
||||
Zeile, das Team-Dropdown darunter. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div class="text-sm text-right">
|
||||
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
|
||||
<p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>
|
||||
<!-- Wer man ist, steht links; was man tun kann, rechts. -->
|
||||
<div class="hidden md:flex min-w-56 flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<a
|
||||
href="/dashboard/settings"
|
||||
title="Profil"
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<Avatar user={auth.user} size={24} />
|
||||
<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}
|
||||
title="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}
|
||||
title="Abmelden"
|
||||
>
|
||||
<LogOut class="h-4 w-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<TeamSwitcher />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
|
||||
<LogOut class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
|
@ -167,14 +200,28 @@
|
|||
</a>
|
||||
{/each}
|
||||
<Separator class="my-2" />
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<div class="text-sm">
|
||||
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
|
||||
<p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>
|
||||
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div class="flex flex-col items-start gap-1 min-w-0">
|
||||
<span class="flex items-center gap-2 min-w-0">
|
||||
<Avatar user={auth.user} size={24} />
|
||||
<span class="text-sm font-medium leading-none truncate">
|
||||
{auth.user.name || auth.user.email}
|
||||
</span>
|
||||
</span>
|
||||
<TeamSwitcher />
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" onclick={toggleMode} title="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" onclick={handleLogout} title="Abmelden">
|
||||
<LogOut class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onclick={handleLogout}>
|
||||
<LogOut class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
@ -184,5 +231,8 @@
|
|||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
|
||||
<RunningTimesToast />
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Calendar, Users, Flag, Timer, Clock, MapPin } from 'lucide-svelte'
|
||||
import { formatDateRange } from '$lib/time'
|
||||
import { Calendar, Users, Clock, MapPin, Route, UsersRound } from 'lucide-svelte'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const events = getEventContext()
|
||||
|
|
@ -16,6 +17,10 @@
|
|||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
// „Aktiv" heißt jetzt: findet heute statt. Für die Startseite ist das
|
||||
// zusammen mit dem Anstehenden interessant.
|
||||
const upNext = $derived([...events.active, ...events.upcoming].slice(0, 4))
|
||||
|
||||
const stats = $derived({
|
||||
totalEvents: events.scoped.length,
|
||||
activeEvents: events.active.length,
|
||||
|
|
@ -37,7 +42,7 @@
|
|||
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/teams" class="underline">Team verwalten</a>.</p>
|
||||
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/settings/team" class="underline">Team verwalten</a>.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -49,7 +54,7 @@
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.totalEvents}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} aktiv</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} heute</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -87,44 +92,48 @@
|
|||
</a>
|
||||
</Card>
|
||||
<Card class="hover:border-primary transition-colors">
|
||||
<a href="/dashboard/timing" class="block p-6">
|
||||
<Timer class="h-6 w-6 text-muted-foreground mb-3" />
|
||||
<h3 class="font-semibold">Zeitnahme</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Zeiten starten und stoppen</p>
|
||||
<a href="/dashboard/trails" class="block p-6">
|
||||
<Route class="h-6 w-6 text-muted-foreground mb-3" />
|
||||
<h3 class="font-semibold">Trails</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Strecken, Zustand und Meldungen</p>
|
||||
</a>
|
||||
</Card>
|
||||
<Card class="hover:border-primary transition-colors">
|
||||
<a href="/dashboard/results" class="block p-6">
|
||||
<Flag class="h-6 w-6 text-muted-foreground mb-3" />
|
||||
<h3 class="font-semibold">Ergebnisse</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Zeiten und Rankings</p>
|
||||
<a href="/dashboard/settings/team" class="block p-6">
|
||||
<UsersRound class="h-6 w-6 text-muted-foreground mb-3" />
|
||||
<h3 class="font-semibold">Teams</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Mitglieder und Rechte</p>
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-4">Aktive Events</h2>
|
||||
{#if events.active.length === 0}
|
||||
<h2 class="text-xl font-semibold mb-4">Heute und demnächst</h2>
|
||||
{#if upNext.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-12 text-center">
|
||||
<Calendar class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p class="font-medium">Keine aktiven Events</p>
|
||||
<p class="font-medium">Nichts geplant</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Aktiviere ein Event über die Eventliste, um mit der Zeitnahme zu beginnen.
|
||||
Trag einem Event ein Datum ein, dann steht es hier.
|
||||
</p>
|
||||
<Button href="/dashboard/events" variant="outline" class="mt-4">Events ansehen</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each events.active as event}
|
||||
{#each upNext as event}
|
||||
{@const eventRuns = runs.getByEvent(event.id).length}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle>{event.name || event.description || 'Unbenannt'}</CardTitle>
|
||||
<Badge>Aktiv</Badge>
|
||||
{#if event.starts}
|
||||
<Badge variant="secondary" class="shrink-0">
|
||||
{formatDateRange(event.starts, event.ends)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.location}
|
||||
<CardDescription class="flex items-center gap-1">
|
||||
|
|
@ -135,7 +144,7 @@
|
|||
</CardHeader>
|
||||
<CardContent class="flex items-center justify-between">
|
||||
<p class="text-sm text-muted-foreground">{eventRuns} Run{eventRuns === 1 ? '' : 's'}</p>
|
||||
<Button href="/dashboard/timing?event={event.id}" size="sm">Zeitnahme</Button>
|
||||
<Button href="/dashboard/events/{event.id}" size="sm">Zum Event</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -7,25 +7,24 @@
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import * as Dialog from '@/components/ui/dialog'
|
||||
import * as Select from '@/components/ui/select'
|
||||
import { dayOf, formatDateRange } from '$lib/time'
|
||||
import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte'
|
||||
import type { EventsResponse, EventsStatusOptions } from '$lib/types'
|
||||
import type { EventsResponse } from '$lib/types'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
|
||||
let showDialog = $state(false)
|
||||
let editing = $state<EventsResponse | null>(null)
|
||||
let form = $state<{
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
status: EventsStatusOptions
|
||||
}>({ name: '', description: '', location: '', status: 'draft' as EventsStatusOptions })
|
||||
let form = $state({ name: '', description: '', location: '', starts: '', ends: '' })
|
||||
let multiDay = $state(false)
|
||||
let formError = $state<string | null>(null)
|
||||
|
||||
function openCreate() {
|
||||
editing = null
|
||||
form = { name: '', description: '', location: '', status: 'draft' as EventsStatusOptions }
|
||||
form = { name: '', description: '', location: '', starts: '', ends: '' }
|
||||
multiDay = false
|
||||
formError = null
|
||||
showDialog = true
|
||||
}
|
||||
|
||||
|
|
@ -35,29 +34,47 @@
|
|||
name: e.name ?? '',
|
||||
description: e.description ?? '',
|
||||
location: e.location ?? '',
|
||||
status: e.status ?? ('draft' as EventsStatusOptions),
|
||||
starts: dayOf(e.starts),
|
||||
ends: dayOf(e.ends),
|
||||
}
|
||||
// Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein
|
||||
// eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache.
|
||||
multiDay = !!dayOf(e.ends)
|
||||
formError = null
|
||||
showDialog = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim()) return
|
||||
|
||||
if (multiDay && !form.starts) {
|
||||
formError = 'Ohne Beginn ergibt ein Ende keinen Sinn.'
|
||||
return
|
||||
}
|
||||
if (multiDay && form.ends && form.ends < form.starts) {
|
||||
formError = 'Das Ende liegt vor dem Beginn.'
|
||||
return
|
||||
}
|
||||
|
||||
formError = null
|
||||
try {
|
||||
if (editing) await events.edit(editing.id, form)
|
||||
else await events.create(form)
|
||||
// Ein eintägiges Event hat kein Ende — sonst stünde überall
|
||||
// dasselbe Datum zweimal.
|
||||
const ends = multiDay && form.ends !== form.starts ? form.ends : ''
|
||||
const data = { ...form, ends }
|
||||
if (editing) await events.edit(editing.id, data)
|
||||
else await events.create(data)
|
||||
showDialog = false
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabels = { draft: 'Entwurf', active: 'Aktiv', finished: 'Abgeschlossen' }
|
||||
const statusVariants = { draft: 'secondary', active: 'default', finished: 'outline' } as const
|
||||
|
||||
const groups = $derived([
|
||||
{ title: 'Aktive Events', items: events.active, status: 'active' as const, showZero: false },
|
||||
{ title: 'Entwürfe', items: events.drafts, status: 'draft' as const, showZero: false },
|
||||
{ title: 'Abgeschlossen', items: events.finished, status: 'finished' as const, showZero: false },
|
||||
{ title: 'Läuft gerade', items: events.active },
|
||||
{ title: 'Anstehend', items: events.upcoming },
|
||||
{ title: 'Ohne Termin', items: events.undated },
|
||||
{ title: 'Vorbei', items: events.past },
|
||||
])
|
||||
</script>
|
||||
|
||||
|
|
@ -102,9 +119,11 @@
|
|||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle class="leading-tight">{event.name || 'Unbenannt'}</CardTitle>
|
||||
<Badge variant={statusVariants[event.status as keyof typeof statusVariants] ?? 'secondary'}>
|
||||
{statusLabels[event.status as keyof typeof statusLabels] ?? 'Entwurf'}
|
||||
</Badge>
|
||||
{#if event.starts}
|
||||
<Badge variant="secondary" class="shrink-0">
|
||||
{formatDateRange(event.starts, event.ends)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.location}
|
||||
<CardDescription class="flex items-center gap-1">
|
||||
|
|
@ -168,19 +187,30 @@
|
|||
<Input id="event-desc" bind:value={form.description} placeholder="Optionale Beschreibung" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select.Root type="single" bind:value={form.status}>
|
||||
<Select.Trigger>
|
||||
{statusLabels[form.status]}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="draft">Entwurf</Select.Item>
|
||||
<Select.Item value="active">Aktiv</Select.Item>
|
||||
<Select.Item value="finished">Abgeschlossen</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Label for="event-starts">Datum</Label>
|
||||
<Input id="event-starts" type="date" bind:value={form.starts} />
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={multiDay}
|
||||
class="size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
mehrtägig
|
||||
</label>
|
||||
|
||||
{#if multiDay}
|
||||
<div class="space-y-2">
|
||||
<Label for="event-ends">Bis</Label>
|
||||
<Input id="event-ends" type="date" bind:value={form.ends} min={form.starts} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formError}
|
||||
<p class="text-sm text-destructive">{formError}</p>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="ghost" onclick={() => (showDialog = false)}>Abbrechen</Button>
|
||||
<Button type="submit">{editing ? 'Speichern' : 'Erstellen'}</Button>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import { goto } from '$app/navigation'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||
import { formatDateRange } from '$lib/time'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -20,18 +24,31 @@
|
|||
Timer,
|
||||
Trash2,
|
||||
Users,
|
||||
Flag,
|
||||
} from 'lucide-svelte'
|
||||
import type { RidersResponse, RunsResponse } from '$lib/types'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
|
||||
const eventRuns = $derived(event ? runs.getByEvent(event.id) : [])
|
||||
|
||||
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Run bzw. Fahrer.
|
||||
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
|
||||
function riderPath(riderId: string) {
|
||||
return `/dashboard/events/${event?.id}/riders/${riderId}`
|
||||
}
|
||||
|
||||
function runPath(runId: string) {
|
||||
return `/dashboard/events/${event?.id}/runs/${runId}`
|
||||
}
|
||||
|
||||
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
||||
let timeDialog = $state(false)
|
||||
|
||||
// Fahrer-Dialog
|
||||
let riderDialog = $state(false)
|
||||
let ridingEdit = $state<RidersResponse | null>(null)
|
||||
|
|
@ -125,9 +142,12 @@
|
|||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-3xl font-bold tracking-tight">{event.name || 'Unbenannt'}</h1>
|
||||
<Badge variant={event.status === 'active' ? 'default' : event.status === 'finished' ? 'outline' : 'secondary'}>
|
||||
{event.status === 'active' ? 'Aktiv' : event.status === 'finished' ? 'Abgeschlossen' : 'Entwurf'}
|
||||
</Badge>
|
||||
{#if event.starts}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<Calendar class="h-3 w-3" />
|
||||
{formatDateRange(event.starts, event.ends)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.location}
|
||||
<p class="mt-1 text-muted-foreground flex items-center gap-1">
|
||||
|
|
@ -139,18 +159,14 @@
|
|||
<p class="mt-2 text-sm text-muted-foreground">{event.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if event.status === 'active'}
|
||||
<Button href="/dashboard/timing?event={event.id}">
|
||||
<Timer class="h-4 w-4 mr-2" />
|
||||
Zeitnahme
|
||||
</Button>
|
||||
{/if}
|
||||
<Button href="/dashboard/results?event={event.id}" variant="outline">
|
||||
<Flag class="h-4 w-4 mr-2" />
|
||||
Ergebnisse
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onclick={() => (timeDialog = true)}
|
||||
disabled={eventRiders.length === 0 || eventRuns.length === 0}
|
||||
>
|
||||
<Timer class="h-4 w-4 mr-2" />
|
||||
Zeit erfassen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
|
@ -164,12 +180,14 @@
|
|||
Fahrer
|
||||
<span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">Teilnehmer dieses Events</p>
|
||||
<p class="text-sm text-muted-foreground">Klick auf einen Fahrer zeigt seine Runs</p>
|
||||
</div>
|
||||
<Button onclick={openRiderCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Fahrer hinzufügen
|
||||
</Button>
|
||||
{#if riders.canManage}
|
||||
<Button onclick={openRiderCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Fahrer hinzufügen
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
|
@ -178,11 +196,17 @@
|
|||
<div class="py-12 text-center">
|
||||
<Users class="h-10 w-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 mb-4">Füge den ersten Fahrer hinzu.</p>
|
||||
<Button onclick={openRiderCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Ersten Fahrer hinzufügen
|
||||
</Button>
|
||||
{#if riders.canManage}
|
||||
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
|
||||
<Button onclick={openRiderCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Ersten Fahrer hinzufügen
|
||||
</Button>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Fahrer legt die Teamleitung an.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
|
|
@ -190,29 +214,39 @@
|
|||
<Table.Row>
|
||||
<Table.Head class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head class="w-40">Zeiten</Table.Head>
|
||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each eventRiders as rider (rider.id)}
|
||||
<Table.Row>
|
||||
{@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length}
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(rider.id))}>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline" class="font-mono">{rider.number || '—'}</Badge>
|
||||
<Badge variant="outline">{rider.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{#if fullName(rider)}
|
||||
{fullName(rider)}
|
||||
{:else}
|
||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||
{/if}
|
||||
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||
<a href={riderPath(rider.id)} class="hover:underline">
|
||||
{#if fullName(rider)}
|
||||
{fullName(rider)}
|
||||
{:else}
|
||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||
{/if}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{done} von {eventRuns.length} Runs
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
||||
{#if riders.canManage}
|
||||
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
|
|
@ -234,7 +268,7 @@
|
|||
Runs
|
||||
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">Einzelne Läufe/Stages des Events</p>
|
||||
<p class="text-sm text-muted-foreground">Klick auf einen Run öffnet die Zeitnahme</p>
|
||||
</div>
|
||||
<Button onclick={openRunCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
|
|
@ -259,14 +293,26 @@
|
|||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Bezeichnung</Table.Head>
|
||||
<Table.Head class="w-48">Zeiten</Table.Head>
|
||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each eventRuns as run (run.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{run.description}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{@const runTimes = times.getByRun(run.id)}
|
||||
{@const active = runTimes.filter((t) => t.status === 'active').length}
|
||||
{@const ranked = runTimes.filter((t) => t.status === 'finished').length}
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(runPath(run.id))}>
|
||||
<Table.Cell class="font-medium">
|
||||
<a href={runPath(run.id)} class="hover:underline">{run.description}</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if active > 0}
|
||||
<Badge class="mr-2">{active} laufend</Badge>
|
||||
{/if}
|
||||
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" onclick={() => openRunEdit(run)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -350,3 +396,5 @@
|
|||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Fahrerübersicht: alle Runs des Events aus Sicht eines Fahrers. Gezeigt
|
||||
* wird je Run seine Zeit, sein Platz und ob gerade gemessen wird — die
|
||||
* Zeitnahme selbst passiert auf der Run-Seite, dorthin führt jede Zeile.
|
||||
*/
|
||||
import { page } from '$app/state'
|
||||
import { goto } from '$app/navigation'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import { TIME_STATUS_LABEL, formatDuration } from '$lib/time'
|
||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import * as Table from '@/components/ui/table'
|
||||
import { ArrowLeft, ChevronRight, Timer, Users } from 'lucide-svelte'
|
||||
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
const clock = ticker()
|
||||
|
||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||
const rider = $derived(page.params.riderId ? riders.getById(page.params.riderId) : undefined)
|
||||
|
||||
const valid = $derived(!!event && !!rider && rider.event === event.id)
|
||||
|
||||
/**
|
||||
* Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
|
||||
* bleibt, was dem Fahrer noch fehlt.
|
||||
*/
|
||||
const rows = $derived.by(() => {
|
||||
if (!valid || !event || !rider) return []
|
||||
|
||||
// clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste
|
||||
// aktuell; bei einer Handvoll Runs ist das billig.
|
||||
const own = times.getByRider(rider.id, clock.now)
|
||||
|
||||
return runs.getByEvent(event.id).map((run) => {
|
||||
const forRun = own.filter((t) => t.run === run.id)
|
||||
const active = forRun.find((t) => t.status === 'active')
|
||||
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
|
||||
const best = forRun
|
||||
.filter((t) => t.status === 'finished')
|
||||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
|
||||
|
||||
return {
|
||||
run,
|
||||
active,
|
||||
best,
|
||||
others: forRun.filter((t) => t.status !== 'active' && t.status !== 'finished'),
|
||||
placement: best ? times.placement(run.id, rider.id) : null,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
|
||||
let timeDialog = $state(false)
|
||||
let dialogRun = $state<string | null>(null)
|
||||
|
||||
function openTimeDialog(runId: string | null = null) {
|
||||
dialogRun = runId
|
||||
timeDialog = true
|
||||
}
|
||||
|
||||
const stats = $derived.by(() => {
|
||||
const finished = rows.filter((r) => r.best)
|
||||
const total = finished.reduce((sum, r) => sum + (r.best?.duration ?? 0), 0)
|
||||
const best = finished
|
||||
.map((r) => r.best?.duration ?? 0)
|
||||
.sort((a, b) => a - b)[0]
|
||||
|
||||
return { done: finished.length, of: rows.length, total, best }
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{rider ? fullName(rider) : 'Fahrer'} – {event?.name ?? 'Event'}</title></svelte:head>
|
||||
|
||||
{#if !valid}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Users class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">Fahrer nicht gefunden</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
|
||||
</p>
|
||||
<Button href="/dashboard/events" variant="outline" class="mt-4">
|
||||
<ArrowLeft class="h-4 w-4 mr-2" />
|
||||
Zur Eventliste
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if event && rider}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<a
|
||||
href="/dashboard/events/{event.id}"
|
||||
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ArrowLeft class="h-3 w-3" />
|
||||
{event.name || 'Event'}
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant="outline" class="text-lg px-3 py-1">{rider.number || '—'}</Badge>
|
||||
<h1 class="text-3xl font-bold tracking-tight">
|
||||
{fullName(rider) || 'Ohne Name'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onclick={() => openTimeDialog()} disabled={rows.length === 0}>
|
||||
<Timer class="h-4 w-4 mr-2" />
|
||||
Zeit erfassen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Runs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Bestzeit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold tabular-nums">
|
||||
{stats.best != null ? formatDuration(stats.best) : '—'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Gesamtzeit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold tabular-nums">
|
||||
{stats.done > 0 ? formatDuration(stats.total) : '—'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Timer class="h-4 w-4 text-primary" />
|
||||
Runs ({rows.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
{#if rows.length === 0}
|
||||
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
|
||||
Für dieses Event gibt es noch keine Runs.
|
||||
</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Run</Table.Head>
|
||||
<Table.Head class="w-24">Status</Table.Head>
|
||||
<Table.Head class="text-right w-28">Zeit</Table.Head>
|
||||
<Table.Head class="text-right w-20">Platz</Table.Head>
|
||||
<Table.Head class="w-10"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as row (row.run.id)}
|
||||
<Table.Row
|
||||
class="cursor-pointer"
|
||||
onclick={() => goto(`/dashboard/events/${event.id}/runs/${row.run.id}`)}
|
||||
>
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href="/dashboard/events/{event.id}/runs/{row.run.id}"
|
||||
class="hover:underline"
|
||||
>
|
||||
{row.run.description || 'Run'}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if row.active}
|
||||
<Badge>Läuft</Badge>
|
||||
{:else if row.best}
|
||||
<Badge variant="secondary">Beendet</Badge>
|
||||
{:else if row.others.length > 0}
|
||||
<Badge variant="outline">
|
||||
{TIME_STATUS_LABEL[row.others[0].status] ?? row.others[0].status}
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">
|
||||
{#if row.active}
|
||||
<span class="font-semibold">{row.active.formattedTime}</span>
|
||||
{:else if row.best}
|
||||
{row.best.formattedTime}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if row.placement}
|
||||
<Badge variant={row.placement <= 3 ? 'default' : 'secondary'} class="w-7 justify-center">
|
||||
{row.placement}
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<ChevronRight class="h-4 w-4 text-muted-foreground" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<TimeDialog
|
||||
bind:open={timeDialog}
|
||||
eventId={event?.id ?? ''}
|
||||
riderId={rider?.id ?? null}
|
||||
runId={dialogRun}
|
||||
/>
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Run-Übersicht: laufende und gewertete Zeiten in einer Tabelle, die
|
||||
* laufenden zählen in Echtzeit mit. Die Tabelle zeigt nur an — gestartet
|
||||
* und gestoppt wird ausschließlich im Zeit-Dialog. Auf dem Handy an der
|
||||
* Strecke ist ein versehentlicher Treffer sonst schnell passiert, und
|
||||
* eine gestoppte Zeit lässt sich nicht wieder aufnehmen.
|
||||
*/
|
||||
import { page } from '$app/state'
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { ticker } from '$lib/stores/ticker.svelte'
|
||||
import { TIME_STATUS_LABEL } from '$lib/time'
|
||||
import TimeDialog from '$lib/components/TimeDialog.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import * as Table from '@/components/ui/table'
|
||||
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
const clock = ticker()
|
||||
|
||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||
const run = $derived(page.params.runId ? runs.getById(page.params.runId) : undefined)
|
||||
|
||||
// Ein Run gehört genau zu einem Event; passt die URL nicht zusammen, ist
|
||||
// sie von Hand zusammengebaut oder veraltet.
|
||||
const valid = $derived(!!event && !!run && run.event === event.id)
|
||||
|
||||
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
|
||||
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
|
||||
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
|
||||
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
|
||||
|
||||
/**
|
||||
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
|
||||
* steht oben ohne Platz, darunter die Rangliste. So sieht man beides auf
|
||||
* einen Blick, statt zwischen zwei Karten zu springen.
|
||||
*/
|
||||
const rows = $derived([
|
||||
...running.map((time) => ({ time, place: null as number | null })),
|
||||
...leaderboard.map((time, i) => ({ time, place: i + 1 })),
|
||||
])
|
||||
|
||||
function rider(id: string | undefined) {
|
||||
return id ? riders.getById(id) : undefined
|
||||
}
|
||||
|
||||
function riderPath(riderId: string) {
|
||||
return `/dashboard/events/${event?.id}/riders/${riderId}`
|
||||
}
|
||||
|
||||
// --- Zeitnahme --------------------------------------------------------
|
||||
// Start und Stopp laufen ausschließlich über TimeDialog; der Run ist
|
||||
// dabei vorbelegt, den Fahrer wählt man dort.
|
||||
let timeDialog = $state(false)
|
||||
|
||||
// --- Export ------------------------------------------------------------
|
||||
function exportCsv() {
|
||||
if (leaderboard.length === 0) return
|
||||
|
||||
const rows = [['Platz', 'Nummer', 'Name', 'Zeit', 'Korrektur (s)']]
|
||||
leaderboard.forEach((t, i) => {
|
||||
const r = rider(t.rider)
|
||||
rows.push([
|
||||
String(i + 1),
|
||||
r?.number ?? '',
|
||||
r ? fullName(r) : '',
|
||||
t.formattedTime ?? '',
|
||||
String(t.correction ?? 0),
|
||||
])
|
||||
})
|
||||
|
||||
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
|
||||
const safeName = (run?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${safeName}-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{run?.description ?? 'Run'} – {event?.name ?? 'Event'}</title></svelte:head>
|
||||
|
||||
{#if !valid}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">Run nicht gefunden</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
|
||||
</p>
|
||||
<Button href="/dashboard/events" variant="outline" class="mt-4">
|
||||
<ArrowLeft class="h-4 w-4 mr-2" />
|
||||
Zur Eventliste
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if event && run}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<a
|
||||
href="/dashboard/events/{event.id}"
|
||||
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ArrowLeft class="h-3 w-3" />
|
||||
{event.name || 'Event'}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
{running.length} laufend · {leaderboard.length} gewertet · {eventRiders.length} Fahrer im Event
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if leaderboard.length > 0}
|
||||
<Button variant="outline" onclick={exportCsv}>
|
||||
<Download class="h-4 w-4 mr-2" />
|
||||
CSV
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={() => (timeDialog = true)} disabled={eventRiders.length === 0}>
|
||||
<Timer class="h-4 w-4 mr-2" />
|
||||
Zeit erfassen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if eventRiders.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-6 text-sm text-muted-foreground">
|
||||
Für dieses Event gibt es noch keine Fahrer.
|
||||
<a href="/dashboard/events/{event.id}" class="underline">Jetzt anlegen</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Zeiten: laufende oben, darunter die Wertung -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Trophy class="h-4 w-4 text-primary" />
|
||||
Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
{#if rows.length === 0}
|
||||
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
|
||||
Noch keine Zeit. Über „Zeitnahme starten" geht es los.
|
||||
</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-16">Platz</Table.Head>
|
||||
<Table.Head class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head class="text-right">Zeit</Table.Head>
|
||||
<Table.Head class="text-right w-24">Korrektur</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as row (row.time.id)}
|
||||
{@const t = row.time}
|
||||
{@const r = rider(t.rider)}
|
||||
{@const live = row.place === null}
|
||||
<Table.Row class={live ? 'bg-primary/5' : undefined}>
|
||||
<Table.Cell>
|
||||
{#if live}
|
||||
<Badge variant="outline" class="text-primary border-primary">läuft</Badge>
|
||||
{:else}
|
||||
<Badge variant={row.place! <= 3 ? 'default' : 'secondary'} class="w-7 justify-center">
|
||||
{row.place}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{r?.number ?? '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{#if r}
|
||||
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums {live ? 'font-semibold' : ''}">
|
||||
{t.formattedTime}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
||||
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : '–'}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if unranked.length > 0}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Flag class="h-4 w-4 text-muted-foreground" />
|
||||
Ohne Wertung ({unranked.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<ul class="divide-y">
|
||||
{#each unranked as t (t.id)}
|
||||
{@const r = rider(t.rider)}
|
||||
<li class="px-6 py-3 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline">{r?.number ?? '—'}</Badge>
|
||||
<span class="truncate">
|
||||
{#if r}
|
||||
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{TIME_STATUS_LABEL[t.status] ?? t.status}</Badge>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} runId={run?.id ?? null} />
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import * as Select from '@/components/ui/select'
|
||||
import * as Table from '@/components/ui/table'
|
||||
import { Trophy, Download, Flag, Medal } from 'lucide-svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
let selectedEventId = $state<string>('')
|
||||
let selectedRunId = $state<string>('')
|
||||
|
||||
onMount(() => {
|
||||
const fromUrl = page.url.searchParams.get('event')
|
||||
if (fromUrl) selectedEventId = fromUrl
|
||||
else if (events.scoped.length > 0) selectedEventId = events.scoped[0].id
|
||||
})
|
||||
|
||||
const availableRuns = $derived(
|
||||
selectedEventId ? runs.getByEvent(selectedEventId) : [],
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
if (selectedEventId && availableRuns.length > 0) {
|
||||
if (!availableRuns.some((r) => r.id === selectedRunId)) {
|
||||
selectedRunId = availableRuns[0].id
|
||||
}
|
||||
} else {
|
||||
selectedRunId = ''
|
||||
}
|
||||
})
|
||||
|
||||
const leaderboard = $derived(selectedRunId ? times.getLeaderboard(selectedRunId) : [])
|
||||
const podium = $derived(leaderboard.slice(0, 3))
|
||||
|
||||
function getRider(id: string | undefined) {
|
||||
return id ? riders.getById(id) : undefined
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
if (leaderboard.length === 0) return
|
||||
const rows = [['Platz', 'Nummer', 'Name', 'Zeit', 'Korrektur (s)']]
|
||||
leaderboard.forEach((t, i) => {
|
||||
const r = getRider(t.rider)
|
||||
rows.push([
|
||||
String(i + 1),
|
||||
r?.number ?? '',
|
||||
r ? fullName(r) : '',
|
||||
t.formattedTime ?? '',
|
||||
String(t.correction ?? 0),
|
||||
])
|
||||
})
|
||||
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
|
||||
const run = runs.getById(selectedRunId)
|
||||
const safeName = (run?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${safeName}-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const selectedEventName = $derived(
|
||||
selectedEventId ? events.getById(selectedEventId)?.name ?? '—' : 'Event wählen…',
|
||||
)
|
||||
const selectedRunName = $derived(
|
||||
selectedRunId ? runs.getById(selectedRunId)?.description ?? '—' : 'Run wählen…',
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Ergebnisse</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Ergebnisse</h1>
|
||||
<p class="mt-1 text-muted-foreground">Zeiten und Rankings</p>
|
||||
</div>
|
||||
{#if leaderboard.length > 0}
|
||||
<Button variant="outline" onclick={exportCsv}>
|
||||
<Download class="h-4 w-4 mr-2" />
|
||||
CSV exportieren
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="py-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Event</Label>
|
||||
<Select.Root type="single" bind:value={selectedEventId}>
|
||||
<Select.Trigger>{selectedEventName}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each events.scoped as e}
|
||||
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Run</Label>
|
||||
<Select.Root type="single" bind:value={selectedRunId} disabled={availableRuns.length === 0}>
|
||||
<Select.Trigger>{selectedRunName}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each availableRuns as r}
|
||||
<Select.Item value={r.id}>{r.description}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if !selectedRunId}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Trophy class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">Wähle Event und Run</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">Um Ergebnisse anzuzeigen, oben auswählen.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if leaderboard.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Flag class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">Noch keine Ergebnisse</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">Für diesen Run wurden noch keine Zeiten erfasst.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
{#if podium.length > 0}
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
{#each podium as t, i}
|
||||
{@const r = getRider(t.rider)}
|
||||
{#if r}
|
||||
<Card class={i === 0 ? 'border-primary md:order-2' : i === 1 ? 'md:order-1' : 'md:order-3'}>
|
||||
<CardContent class="py-6 text-center">
|
||||
<Medal class={`h-8 w-8 mx-auto mb-2 ${i === 0 ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<p class="text-sm text-muted-foreground">Platz {i + 1}</p>
|
||||
<p class="text-lg font-semibold mt-1">#{r.number} {fullName(r)}</p>
|
||||
<p class="font-mono text-2xl font-bold mt-2 tabular-nums">{t.formattedTime}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vollständige Rangliste</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-16">Platz</Table.Head>
|
||||
<Table.Head class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head class="text-right">Zeit</Table.Head>
|
||||
<Table.Head class="text-right w-24">Korrektur</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each leaderboard as t, i (t.id)}
|
||||
{@const r = getRider(t.rider)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Badge variant={i < 3 ? 'default' : 'secondary'} class="font-mono w-7 justify-center">
|
||||
{i + 1}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline" class="font-mono">{r?.number ?? '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">{r ? fullName(r) : '—'}</Table.Cell>
|
||||
<Table.Cell class="text-right font-mono tabular-nums">{t.formattedTime}</Table.Cell>
|
||||
<Table.Cell class="text-right font-mono text-muted-foreground tabular-nums">
|
||||
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : '–'}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
143
frontend/src/routes/dashboard/riders/+page.svelte
Normal file
143
frontend/src/routes/dashboard/riders/+page.svelte
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<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 { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.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 events = getEventContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
let query = $state('')
|
||||
|
||||
const roster = $derived.by(() => {
|
||||
const q = query.toLowerCase().trim()
|
||||
if (!q) return riders.scoped
|
||||
|
||||
return riders.scoped.filter(
|
||||
(r) =>
|
||||
(r.number ?? '').toLowerCase().includes(q) ||
|
||||
fullName(r).toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
teams.loadMembers()
|
||||
})
|
||||
|
||||
function eventName(id: string | undefined) {
|
||||
if (!id) return null
|
||||
return events.getById(id)?.name ?? 'Unbekanntes Event'
|
||||
}
|
||||
|
||||
/** Zur Fahrerseite geht es nur mit Event — Zeiten hängen daran. */
|
||||
function riderPath(r: { id: string; event?: string }) {
|
||||
return r.event ? `/dashboard/events/${r.event}/riders/${r.id}` : 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="Nummer oder 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 class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Event</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)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{r.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<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">
|
||||
{eventName(r.event) ?? '—'}
|
||||
</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>
|
||||
57
frontend/src/routes/dashboard/settings/+layout.svelte
Normal file
57
frontend/src/routes/dashboard/settings/+layout.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
|
||||
* Der Reiter „Fahrer" erscheint nur für die Teamleitung — dort wird
|
||||
* angelegt und verknüpft, was alle anderen nur unter „Fahrer" im
|
||||
* Hauptmenü zu sehen bekommen.
|
||||
*/
|
||||
import { page } from '$app/state'
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { Flag, User, Users, Bike } from 'lucide-svelte'
|
||||
|
||||
let { children } = $props()
|
||||
|
||||
const teams = getTeamContext()
|
||||
|
||||
const tabs = $derived([
|
||||
{ href: '/dashboard/settings', label: 'Profil', icon: User },
|
||||
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
|
||||
...(teams.isAdmin()
|
||||
? [{ href: '/dashboard/settings/riders', label: 'Fahrer', icon: Bike }]
|
||||
: []),
|
||||
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
|
||||
])
|
||||
|
||||
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
|
||||
// auf jedem Unterpfad mit aktiv.
|
||||
function isActive(href: string) {
|
||||
return href === '/dashboard/settings'
|
||||
? page.url.pathname === href
|
||||
: page.url.pathname.startsWith(href)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Einstellungen</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Einstellungen</h1>
|
||||
|
||||
<nav class="flex flex-wrap gap-1 border-b">
|
||||
{#each tabs as tab (tab.href)}
|
||||
{@const Icon = tab.icon}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
|
||||
class:border-primary={isActive(tab.href)}
|
||||
class:text-foreground={isActive(tab.href)}
|
||||
class:border-transparent={!isActive(tab.href)}
|
||||
class:text-muted-foreground={!isActive(tab.href)}
|
||||
>
|
||||
<Icon class="size-4" />
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
190
frontend/src/routes/dashboard/settings/+page.svelte
Normal file
190
frontend/src/routes/dashboard/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Eigenes Profil. Die users-Collection erlaubt Änderungen nur am eigenen
|
||||
* Datensatz (updateRule `id = @request.auth.id`) — was hier steht, kann
|
||||
* also niemand für jemand anderen ändern.
|
||||
*/
|
||||
import { api, auth } from '$lib/stores/pocketbase.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { KeyRound, User } from 'lucide-svelte'
|
||||
|
||||
let name = $state(auth.user?.name ?? '')
|
||||
let avatarFile = $state<File | null>(null)
|
||||
let savingProfile = $state(false)
|
||||
let profileMessage = $state<string | null>(null)
|
||||
let profileError = $state<string | null>(null)
|
||||
|
||||
async function saveProfile() {
|
||||
if (!auth.user) return
|
||||
|
||||
savingProfile = true
|
||||
profileMessage = null
|
||||
profileError = null
|
||||
try {
|
||||
// Mit Datei als FormData, sonst als einfaches Objekt — ein leeres
|
||||
// avatar-Feld im FormData würde ein vorhandenes Bild löschen.
|
||||
let data: FormData | Record<string, unknown>
|
||||
if (avatarFile) {
|
||||
const fd = new FormData()
|
||||
fd.append('name', name.trim())
|
||||
fd.append('avatar', avatarFile)
|
||||
data = fd
|
||||
} else {
|
||||
data = { name: name.trim() }
|
||||
}
|
||||
|
||||
await api.collection('users').update(auth.user.id, data)
|
||||
await auth.refresh()
|
||||
|
||||
avatarFile = null
|
||||
profileMessage = 'Profil gespeichert.'
|
||||
} catch (e: any) {
|
||||
profileError = e.message ?? 'Das Profil konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
savingProfile = false
|
||||
}
|
||||
}
|
||||
|
||||
let oldPassword = $state('')
|
||||
let newPassword = $state('')
|
||||
let confirmPassword = $state('')
|
||||
let savingPassword = $state(false)
|
||||
let passwordMessage = $state<string | null>(null)
|
||||
let passwordError = $state<string | null>(null)
|
||||
|
||||
async function savePassword() {
|
||||
if (!auth.user) return
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
passwordError = 'Das neue Passwort braucht mindestens 8 Zeichen.'
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
passwordError = 'Die Wiederholung stimmt nicht überein.'
|
||||
return
|
||||
}
|
||||
|
||||
savingPassword = true
|
||||
passwordMessage = null
|
||||
passwordError = null
|
||||
try {
|
||||
await api.collection('users').update(auth.user.id, {
|
||||
oldPassword,
|
||||
password: newPassword,
|
||||
passwordConfirm: confirmPassword,
|
||||
})
|
||||
|
||||
// PocketBase entwertet mit dem Passwortwechsel alle Tokens; ohne
|
||||
// neue Anmeldung wäre die Sitzung ab hier tot.
|
||||
await api.collection('users').authWithPassword(auth.user.email as string, newPassword)
|
||||
if (auth.cookie) {
|
||||
document.cookie = api.authStore.exportToCookie({ httpOnly: false })
|
||||
}
|
||||
|
||||
oldPassword = newPassword = confirmPassword = ''
|
||||
passwordMessage = 'Passwort geändert.'
|
||||
} catch (e: any) {
|
||||
passwordError = e.message ?? 'Das Passwort konnte nicht geändert werden.'
|
||||
} finally {
|
||||
savingPassword = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<User class="size-4 text-primary" />
|
||||
Konto
|
||||
{#if auth.user?.superadmin}
|
||||
<!-- Appweite Rolle; vergeben wird sie nur in PocketBase. -->
|
||||
<Badge variant="secondary">Superadmin</Badge>
|
||||
{/if}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar user={auth.user} size={64} />
|
||||
<div class="space-y-1">
|
||||
<Label for="avatar">Bild</Label>
|
||||
<input
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="block w-full text-sm"
|
||||
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Ohne Bild stehen deine Initialen da.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="profile-name">Name</Label>
|
||||
<Input id="profile-name" bind:value={name} placeholder="Wie du angezeigt wirst" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="profile-mail">E-Mail</Label>
|
||||
<Input id="profile-mail" value={auth.user?.email ?? ''} readonly disabled />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
|
||||
Team-Administration über PocketBase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if profileError}
|
||||
<p class="text-sm text-destructive">{profileError}</p>
|
||||
{:else if profileMessage}
|
||||
<p class="text-sm text-muted-foreground">{profileMessage}</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={saveProfile} disabled={savingProfile}>
|
||||
{savingProfile ? 'Wird gespeichert …' : 'Speichern'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<KeyRound class="size-4 text-primary" />
|
||||
Passwort ändern
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="pw-old">Aktuelles Passwort</Label>
|
||||
<Input id="pw-old" type="password" bind:value={oldPassword} autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="pw-new">Neues Passwort</Label>
|
||||
<Input id="pw-new" type="password" bind:value={newPassword} autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="pw-confirm">Wiederholen</Label>
|
||||
<Input id="pw-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
{#if passwordError}
|
||||
<p class="text-sm text-destructive">{passwordError}</p>
|
||||
{:else if passwordMessage}
|
||||
<p class="text-sm text-muted-foreground">{passwordMessage}</p>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
onclick={savePassword}
|
||||
disabled={savingPassword || !oldPassword || !newPassword}
|
||||
>
|
||||
{savingPassword ? 'Wird geändert …' : 'Passwort ändern'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import * as Dialog from '@/components/ui/dialog'
|
||||
import FlagIcon from '$lib/components/FlagIcon.svelte'
|
||||
import IconPicker from '$lib/components/IconPicker.svelte'
|
||||
import { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte'
|
||||
import type { TrailFlagsResponse } from '$lib/types'
|
||||
|
||||
|
|
@ -104,10 +106,9 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Flag-Typen</h1>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Wofür sich Marker auf einem Trail setzen lassen.
|
||||
</p>
|
||||
|
|
@ -149,7 +150,7 @@
|
|||
{#each flags.scoped as flag (flag.id)}
|
||||
<Card>
|
||||
<CardContent class="py-3 flex items-center gap-3">
|
||||
<span class="size-4 rounded-full shrink-0" style:background={flag.color}></span>
|
||||
<FlagIcon name={flag.icon} color={flag.color} class="size-5" />
|
||||
<span class="font-medium flex-1">{flag.label}</span>
|
||||
<Badge variant={flag.severity === 'kritisch' ? 'destructive' : 'secondary'}>
|
||||
{flag.severity}
|
||||
|
|
@ -201,13 +202,8 @@
|
|||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="flag-icon">Icon</Label>
|
||||
<Input id="flag-icon" bind:value={form.icon} placeholder="Lucide-Name, z. B. TreePine" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Name eines Icons von lucide.dev, in Schreibweise wie „TreePine".
|
||||
</p>
|
||||
</div>
|
||||
<IconPicker id="flag-icon" bind:value={form.icon} color={form.color} />
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
|
|
|
|||
385
frontend/src/routes/dashboard/settings/riders/+page.svelte
Normal file
385
frontend/src/routes/dashboard/settings/riders/+page.svelte
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Fahrerverwaltung — nur für die Teamleitung. Was alle sehen dürfen,
|
||||
* steht unter „Fahrer" im Hauptmenü; angelegt, geändert und mit einem
|
||||
* Login verknüpft wird ausschließlich hier.
|
||||
*/
|
||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getEventContext } from '$lib/stores/events.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 Select from '@/components/ui/select'
|
||||
import * as Table from '@/components/ui/table'
|
||||
import { KeyRound, Pencil, Plus, Trash2, Unlink, UserCheck } from 'lucide-svelte'
|
||||
import type { RidersResponse } from '$lib/types'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const riders = getRiderContext()
|
||||
const events = getEventContext()
|
||||
|
||||
const canManage = $derived(riders.canManage)
|
||||
const roster = $derived(riders.scoped)
|
||||
|
||||
$effect(() => {
|
||||
teams.loadMembers()
|
||||
})
|
||||
|
||||
function eventName(id: string | undefined) {
|
||||
if (!id) return null
|
||||
return events.getById(id)?.name ?? 'Unbekanntes Event'
|
||||
}
|
||||
|
||||
// --- Fahrer anlegen und ändern ---------------------------------------
|
||||
let riderDialog = $state(false)
|
||||
let editing = $state<RidersResponse | null>(null)
|
||||
let form = $state({ number: '', firstname: '', lastname: '', event: '' })
|
||||
let riderError = $state<string | null>(null)
|
||||
|
||||
function openCreate() {
|
||||
editing = null
|
||||
form = { number: '', firstname: '', lastname: '', event: '' }
|
||||
riderError = null
|
||||
riderDialog = true
|
||||
}
|
||||
|
||||
function openEdit(r: RidersResponse) {
|
||||
editing = r
|
||||
form = {
|
||||
number: r.number ?? '',
|
||||
firstname: r.firstname ?? '',
|
||||
lastname: r.lastname ?? '',
|
||||
event: r.event ?? '',
|
||||
}
|
||||
riderError = null
|
||||
riderDialog = true
|
||||
}
|
||||
|
||||
async function saveRider() {
|
||||
if (!form.number.trim()) {
|
||||
riderError = 'Die Startnummer ist Pflicht.'
|
||||
return
|
||||
}
|
||||
// Startnummern sind je Event eindeutig; Fahrer ohne Event teilen sich
|
||||
// den gemeinsamen Topf des Teams.
|
||||
if (riders.isNumberTaken(form.number.trim(), form.event, editing?.id)) {
|
||||
riderError = `Startnummer ${form.number} ist dort bereits vergeben.`
|
||||
return
|
||||
}
|
||||
|
||||
riderError = null
|
||||
try {
|
||||
const data = {
|
||||
number: form.number.trim(),
|
||||
firstname: form.firstname.trim(),
|
||||
lastname: form.lastname.trim(),
|
||||
event: form.event,
|
||||
}
|
||||
if (editing) await riders.edit(editing.id, data)
|
||||
else await riders.create(data)
|
||||
riderDialog = false
|
||||
} catch (e: any) {
|
||||
riderError = e.message ?? 'Der Fahrer konnte nicht gespeichert werden.'
|
||||
}
|
||||
}
|
||||
|
||||
// --- Login verknüpfen -------------------------------------------------
|
||||
let loginDialog = $state(false)
|
||||
let loginRider = $state<RidersResponse | null>(null)
|
||||
let loginMode = $state<'link' | 'create'>('link')
|
||||
let linkUserId = $state('')
|
||||
let loginForm = $state({ email: '', password: '' })
|
||||
let loginBusy = $state(false)
|
||||
let loginError = $state<string | null>(null)
|
||||
|
||||
/** Teammitglieder, die noch an keinem anderen Fahrer hängen. */
|
||||
const linkCandidates = $derived.by(() => {
|
||||
const taken = new Set(
|
||||
roster.filter((r) => r.user && r.id !== loginRider?.id).map((r) => r.user),
|
||||
)
|
||||
return (teams.active?.users ?? []).filter((id) => !taken.has(id))
|
||||
})
|
||||
|
||||
function openLogin(r: RidersResponse) {
|
||||
loginRider = r
|
||||
loginMode = 'link'
|
||||
linkUserId = r.user ?? ''
|
||||
loginForm = { email: '', password: '' }
|
||||
loginError = null
|
||||
loginDialog = true
|
||||
}
|
||||
|
||||
async function doLink() {
|
||||
if (!loginRider || !linkUserId) return
|
||||
|
||||
loginBusy = true
|
||||
loginError = null
|
||||
try {
|
||||
await riders.linkUser(loginRider.id, linkUserId)
|
||||
loginDialog = false
|
||||
} catch (e: any) {
|
||||
loginError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
|
||||
} finally {
|
||||
loginBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doUnlink(r: RidersResponse) {
|
||||
try {
|
||||
await riders.linkUser(r.id, null)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
|
||||
}
|
||||
}
|
||||
|
||||
async function doCreateLogin() {
|
||||
if (!loginRider) return
|
||||
|
||||
if (!loginForm.email.trim()) {
|
||||
loginError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
|
||||
return
|
||||
}
|
||||
if (loginForm.password.length < 8) {
|
||||
loginError = 'Das Passwort braucht mindestens 8 Zeichen.'
|
||||
return
|
||||
}
|
||||
|
||||
loginBusy = true
|
||||
loginError = null
|
||||
try {
|
||||
await riders.createLogin(loginRider, { ...loginForm })
|
||||
loginDialog = false
|
||||
} catch (e: any) {
|
||||
loginError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
loginBusy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !canManage}
|
||||
<Card>
|
||||
<CardContent class="py-12 text-center text-muted-foreground">
|
||||
Fahrer verwaltet, wer das Team führt. Die Fahrer deines Teams siehst du
|
||||
unter <a href="/dashboard/riders" class="underline">Fahrer</a>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst
|
||||
anzumelden.
|
||||
</p>
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="size-4 mr-2" />
|
||||
Fahrer anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if roster.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-12 text-center text-muted-foreground">
|
||||
Noch keine Fahrer im Team.
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<CardContent class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Event</Table.Head>
|
||||
<Table.Head>Konto</Table.Head>
|
||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each roster as r (r.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{r.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{fullName(r) || '— ohne Name —'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{eventName(r.event) ?? '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if r.user}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<UserCheck class="size-3" />
|
||||
kann sich einloggen
|
||||
</Badge>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{teams.userLabel(r.user)}
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right whitespace-nowrap">
|
||||
{#if r.user}
|
||||
<Button variant="ghost" size="icon" title="Verknüpfung lösen" onclick={() => doUnlink(r)}>
|
||||
<Unlink class="size-4" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" size="icon" title="Konto verknüpfen" onclick={() => openLogin(r)}>
|
||||
<KeyRound class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="icon" title="Bearbeiten" onclick={() => openEdit(r)}>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="Löschen" onclick={() => riders.remove(r)}>
|
||||
<Trash2 class="size-4 text-destructive" />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Fahrer anlegen/bearbeiten -->
|
||||
<Dialog.Root bind:open={riderDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="rider-number">Startnummer</Label>
|
||||
<Input id="rider-number" bind:value={form.number} placeholder="42" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="rider-first">Vorname</Label>
|
||||
<Input id="rider-first" bind:value={form.firstname} placeholder="Max" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="rider-last">Nachname</Label>
|
||||
<Input id="rider-last" bind:value={form.lastname} placeholder="Mustermann" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Event</Label>
|
||||
<Select.Root type="single" bind:value={form.event}>
|
||||
<Select.Trigger class="w-full">
|
||||
{eventName(form.event) ?? 'Keinem Event zugeordnet'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">Keinem Event zugeordnet</Select.Item>
|
||||
{#each events.scoped as e (e.id)}
|
||||
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Zeiten hängen am Event. Ohne Zuordnung steht der Fahrer nur im
|
||||
Team-Verzeichnis.
|
||||
</p>
|
||||
</div>
|
||||
{#if riderError}
|
||||
<p class="text-sm text-destructive">{riderError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={saveRider}>{editing ? 'Speichern' : 'Anlegen'}</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Konto verknüpfen oder anlegen -->
|
||||
<Dialog.Root bind:open={loginDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Konto für {loginRider ? fullName(loginRider) : '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={loginMode === 'link' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (loginMode = 'link')}
|
||||
>
|
||||
Vorhandenes verknüpfen
|
||||
</Button>
|
||||
<Button
|
||||
variant={loginMode === 'create' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (loginMode = 'create')}
|
||||
>
|
||||
Neues anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if loginMode === 'link'}
|
||||
<div class="space-y-2">
|
||||
<Label>Teammitglied</Label>
|
||||
<Select.Root type="single" bind:value={linkUserId}>
|
||||
<Select.Trigger class="w-full">
|
||||
{linkUserId ? teams.userLabel(linkUserId) : 'Mitglied wählen…'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each linkCandidates as id (id)}
|
||||
<Select.Item value={id}>{teams.userLabel(id)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if linkCandidates.length === 0}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Alle Teammitglieder sind bereits einem Fahrer zugeordnet.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<Label for="login-mail">E-Mail</Label>
|
||||
<Input id="login-mail" type="email" bind:value={loginForm.email} placeholder="max@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="login-pw">Passwort</Label>
|
||||
<Input id="login-pw" type="password" bind:value={loginForm.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 loginError}
|
||||
<p class="text-sm text-destructive">{loginError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (loginDialog = false)}>Abbrechen</Button>
|
||||
{#if loginMode === 'link'}
|
||||
<Button onclick={doLink} disabled={!linkUserId || loginBusy}>Verknüpfen</Button>
|
||||
{:else}
|
||||
<Button onclick={doCreateLogin} disabled={loginBusy}>
|
||||
{loginBusy ? 'Wird angelegt …' : 'Konto anlegen'}
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
347
frontend/src/routes/dashboard/settings/team/+page.svelte
Normal file
347
frontend/src/routes/dashboard/settings/team/+page.svelte
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
<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 { 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 { Crown, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
|
||||
import type { TeamsResponse } from '$lib/types'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const flags = getTrailFlagContext()
|
||||
const me = $derived(api.authStore.record?.id ?? null)
|
||||
|
||||
// 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.'
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mitglied anlegen ----
|
||||
// Bestehende Konten lassen sich nicht suchen: users.listRule gibt nur den
|
||||
// eigenen Datensatz frei, und das soll auch so bleiben. Ein neues Konto
|
||||
// anzulegen deckt den Alltag ab — Vereinsmitglieder haben vorher keins.
|
||||
let memberDialog = $state(false)
|
||||
let memberTeam = $state<TeamsResponse | null>(null)
|
||||
let memberForm = $state({ name: '', email: '', password: '' })
|
||||
let memberBusy = $state(false)
|
||||
let memberError = $state<string | null>(null)
|
||||
|
||||
function openMember(t: TeamsResponse) {
|
||||
memberTeam = t
|
||||
memberForm = { name: '', email: '', password: '' }
|
||||
memberError = null
|
||||
memberDialog = true
|
||||
}
|
||||
|
||||
async function doCreateMember() {
|
||||
if (!memberTeam) return
|
||||
|
||||
if (!memberForm.email.trim()) {
|
||||
memberError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
|
||||
return
|
||||
}
|
||||
if (memberForm.password.length < 8) {
|
||||
memberError = 'Das Passwort braucht mindestens 8 Zeichen.'
|
||||
return
|
||||
}
|
||||
|
||||
memberBusy = true
|
||||
memberError = null
|
||||
try {
|
||||
await teams.createMember({ ...memberForm }, memberTeam.id)
|
||||
memberDialog = false
|
||||
} catch (e: any) {
|
||||
memberError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
memberBusy = 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemoveMember(t: TeamsResponse, userId: string) {
|
||||
if (userId === t.owner) return alert('Der Owner kann nicht entfernt werden.')
|
||||
if (!confirm('Mitglied wirklich entfernen?')) return
|
||||
try {
|
||||
await teams.removeMember(t.id, userId)
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
<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 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={() => openMember(t)}>
|
||||
<UserPlus class="size-4 mr-1" />
|
||||
Mitglied
|
||||
</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" title="Team löschen" onclick={() => teams.remove(t)}>
|
||||
<Trash2 class="size-4 text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul class="divide-y border-t">
|
||||
{#each (t.users ?? []) as userId (userId)}
|
||||
{@const isOwner = userId === t.owner}
|
||||
{@const isAdm = (t.admins ?? []).includes(userId)}
|
||||
<li class="py-3 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Avatar user={teams.users[userId]} size={28} />
|
||||
<span class="font-medium truncate">{teams.userLabel(userId)}</span>
|
||||
{#if isOwner}
|
||||
<Badge class="gap-1">
|
||||
<Crown class="size-3" />
|
||||
Owner
|
||||
</Badge>
|
||||
{:else if isAdm}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<Shield class="size-3" />
|
||||
Admin
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if userId === me}
|
||||
<Badge variant="outline">Du</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if iAmOwner && !isOwner}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{#if isAdm}
|
||||
<Button variant="ghost" size="sm" onclick={() => teams.demoteFromAdmin(t.id, userId)}>
|
||||
Admin entziehen
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" size="sm" onclick={() => teams.promoteToAdmin(t.id, userId)}>
|
||||
Zu Admin
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="icon" title="Entfernen" onclick={() => doRemoveMember(t, userId)}>
|
||||
<UserMinus class="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
<!-- Mitglied anlegen -->
|
||||
<Dialog.Root bind:open={memberDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Mitglied anlegen</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="member-name">Name</Label>
|
||||
<Input id="member-name" bind:value={memberForm.name} placeholder="Max Mustermann" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="member-mail">E-Mail</Label>
|
||||
<Input id="member-mail" type="email" bind:value={memberForm.email} placeholder="max@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="member-pw">Passwort</Label>
|
||||
<Input id="member-pw" type="password" bind:value={memberForm.password} placeholder="mindestens 8 Zeichen" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Gib es der Person weiter; sie kann es unter „Profil" ändern.
|
||||
</p>
|
||||
</div>
|
||||
{#if memberError}
|
||||
<p class="text-sm text-destructive">{memberError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
|
||||
<Button onclick={doCreateMember} disabled={memberBusy}>
|
||||
{memberBusy ? 'Wird angelegt …' : 'Anlegen'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -1,341 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { auth, api } from '$lib/stores/pocketbase.svelte'
|
||||
import { getTeamContext } from '$lib/stores/teams.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 { Crown, Plus, Trash2, UserMinus, UserPlus, Users, Shield, LogOut } from 'lucide-svelte'
|
||||
import type { TeamsResponse, UsersResponse } from '$lib/types'
|
||||
|
||||
const teams = getTeamContext()
|
||||
const me = $derived(api.authStore.record?.id ?? null)
|
||||
|
||||
// Member-Cache: User-Info pro ID
|
||||
let userCache = $state<Record<string, UsersResponse>>({})
|
||||
|
||||
async function loadUser(id: string) {
|
||||
if (userCache[id]) return userCache[id]
|
||||
try {
|
||||
const u = await api.collection('users').getOne(id, { requestKey: null })
|
||||
userCache = { ...userCache, [id]: u }
|
||||
return u
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Bei Team-Änderung alle Mitglieder vorladen
|
||||
for (const t of teams.records) {
|
||||
const ids = [...(t.users ?? []), t.owner].filter(Boolean) as string[]
|
||||
for (const id of ids) {
|
||||
if (!userCache[id]) loadUser(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function userLabel(id: string) {
|
||||
const u = userCache[id]
|
||||
if (!u) return id.slice(0, 6) + '…'
|
||||
return u.name || u.email || u.id.slice(0, 6) + '…'
|
||||
}
|
||||
|
||||
// ---- Team-Create Dialog ----
|
||||
let createDialog = $state(false)
|
||||
let createName = $state('')
|
||||
|
||||
async function doCreate() {
|
||||
if (!createName.trim()) return
|
||||
try {
|
||||
await teams.create(createName.trim())
|
||||
createDialog = false
|
||||
createName = ''
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Member-Invite Dialog ----
|
||||
let inviteDialog = $state(false)
|
||||
let inviteTargetTeam = $state<TeamsResponse | null>(null)
|
||||
let inviteEmail = $state('')
|
||||
let inviteSearching = $state(false)
|
||||
let inviteFound = $state<UsersResponse | null>(null)
|
||||
let inviteError = $state<string | null>(null)
|
||||
|
||||
function openInvite(t: TeamsResponse) {
|
||||
inviteTargetTeam = t
|
||||
inviteEmail = ''
|
||||
inviteFound = null
|
||||
inviteError = null
|
||||
inviteDialog = true
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
if (!inviteEmail.trim()) return
|
||||
inviteSearching = true
|
||||
inviteError = null
|
||||
inviteFound = null
|
||||
try {
|
||||
const u = await teams.findUserByEmail(inviteEmail.trim())
|
||||
if (u) {
|
||||
inviteFound = u
|
||||
userCache = { ...userCache, [u.id]: u }
|
||||
} else {
|
||||
inviteError = `Kein User mit "${inviteEmail}" gefunden`
|
||||
}
|
||||
} catch (e: any) {
|
||||
inviteError = e.message
|
||||
} finally {
|
||||
inviteSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doInvite() {
|
||||
if (!inviteTargetTeam || !inviteFound) return
|
||||
try {
|
||||
await teams.addMember(inviteTargetTeam.id, inviteFound.id)
|
||||
inviteDialog = false
|
||||
} catch (e: any) {
|
||||
inviteError = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemoveMember(t: TeamsResponse, userId: string) {
|
||||
if (userId === t.owner) return alert('Owner kann nicht entfernt werden')
|
||||
if (!confirm(`Mitglied wirklich entfernen?`)) return
|
||||
try {
|
||||
await teams.removeMember(t.id, userId)
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function doPromote(t: TeamsResponse, userId: string) {
|
||||
try {
|
||||
await teams.promoteToAdmin(t.id, userId)
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function doDemote(t: TeamsResponse, userId: string) {
|
||||
try {
|
||||
await teams.demoteFromAdmin(t.id, userId)
|
||||
} catch (e: any) {
|
||||
alert(`Fehler: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isAdmin(t: TeamsResponse, userId: string) {
|
||||
return (t.admins ?? []).includes(userId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Teams – Zeitnahme</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Teams</h1>
|
||||
<p class="mt-1 text-muted-foreground">Deine Teams und Mitglieder verwalten</p>
|
||||
</div>
|
||||
<Button onclick={() => (createDialog = true)}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Neues Team
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if teams.records.length === 0}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Users class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg 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="h-4 w-4 mr-2" />
|
||||
Team erstellen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each teams.records as t (t.id)}
|
||||
{@const iAmOwner = t.owner === me}
|
||||
{@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
{t.name}
|
||||
{#if t.id === teams.activeId}
|
||||
<Badge variant="default">Aktiv</Badge>
|
||||
{/if}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div class="flex 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={() => openInvite(t)}>
|
||||
<UserPlus class="h-4 w-4 mr-1" />
|
||||
Mitglied
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !iAmOwner}
|
||||
<Button variant="ghost" size="sm" onclick={() => doLeave(t)}>
|
||||
<LogOut class="h-4 w-4 mr-1" />
|
||||
Verlassen
|
||||
</Button>
|
||||
{/if}
|
||||
{#if iAmOwner}
|
||||
<Button variant="ghost" size="icon" onclick={() => teams.remove(t)}>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul class="divide-y border-t">
|
||||
{#each (t.users ?? []) as userId}
|
||||
{@const isOwner = userId === t.owner}
|
||||
{@const isAdm = isAdmin(t, userId)}
|
||||
<li class="py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{userLabel(userId)}</span>
|
||||
{#if isOwner}
|
||||
<Badge variant="default" class="gap-1">
|
||||
<Crown class="h-3 w-3" />
|
||||
Owner
|
||||
</Badge>
|
||||
{:else if isAdm}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<Shield class="h-3 w-3" />
|
||||
Admin
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if userId === me}
|
||||
<Badge variant="outline">Du</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if iAmOwner && !isOwner}
|
||||
<div class="flex items-center gap-1">
|
||||
{#if isAdm}
|
||||
<Button variant="ghost" size="sm" onclick={() => doDemote(t, userId)}>
|
||||
Admin entziehen
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" size="sm" onclick={() => doPromote(t, userId)}>
|
||||
Zu Admin
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" size="icon" onclick={() => doRemoveMember(t, userId)}>
|
||||
<UserMinus class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create-Dialog -->
|
||||
<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>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
doCreate()
|
||||
}}
|
||||
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" required />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="ghost" onclick={() => (createDialog = false)}>Abbrechen</Button>
|
||||
<Button type="submit">Erstellen</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Invite-Dialog -->
|
||||
<Dialog.Root bind:open={inviteDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Mitglied hinzufügen</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Suche einen registrierten User über seine E-Mail-Adresse.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
doSearch()
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="invite-email">E-Mail</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="invite-email" type="email" bind:value={inviteEmail} placeholder="user@example.com" />
|
||||
<Button type="submit" variant="outline" disabled={inviteSearching}>
|
||||
{inviteSearching ? 'Suche…' : 'Suchen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if inviteError}
|
||||
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{inviteError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if inviteFound}
|
||||
<div class="rounded-md border bg-accent/40 p-3">
|
||||
<p class="font-medium">{inviteFound.name || inviteFound.email}</p>
|
||||
{#if inviteFound.email}
|
||||
<p class="text-sm text-muted-foreground">{inviteFound.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="ghost" onclick={() => (inviteDialog = false)}>Abbrechen</Button>
|
||||
<Button type="button" disabled={!inviteFound} onclick={doInvite}>Hinzufügen</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getEventContext } from '$lib/stores/events.svelte'
|
||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, 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 Select from '@/components/ui/select'
|
||||
import { Play, Square, Clock, Trophy, Search, Timer } from 'lucide-svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
let selectedEventId = $state<string>('')
|
||||
let selectedRunId = $state<string>('')
|
||||
let searchQuery = $state('')
|
||||
let tick = $state(0)
|
||||
|
||||
onMount(() => {
|
||||
const fromUrl = page.url.searchParams.get('event')
|
||||
if (fromUrl) {
|
||||
selectedEventId = fromUrl
|
||||
} else if (events.active.length > 0) {
|
||||
selectedEventId = events.active[0].id
|
||||
} else if (events.scoped.length > 0) {
|
||||
selectedEventId = events.scoped[0].id
|
||||
}
|
||||
|
||||
const interval = setInterval(() => (tick++), 100)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
const availableRuns = $derived(
|
||||
selectedEventId ? runs.getByEvent(selectedEventId) : [],
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
// Wenn Event wechselt und der bisherige Run nicht dazugehört: ersten Run wählen
|
||||
if (selectedEventId && availableRuns.length > 0) {
|
||||
if (!availableRuns.some((r) => r.id === selectedRunId)) {
|
||||
selectedRunId = availableRuns[0].id
|
||||
}
|
||||
} else {
|
||||
selectedRunId = ''
|
||||
}
|
||||
})
|
||||
|
||||
const eventRiders = $derived(
|
||||
selectedEventId ? riders.getByEvent(selectedEventId) : [],
|
||||
)
|
||||
|
||||
const filteredRiders = $derived(() => {
|
||||
const q = searchQuery.toLowerCase().trim()
|
||||
if (!q) return eventRiders
|
||||
return eventRiders.filter((r) => {
|
||||
return (
|
||||
(r.number ?? '').toLowerCase().includes(q) ||
|
||||
fullName(r).toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const runTimes = $derived(selectedRunId ? times.getByRun(selectedRunId) : [])
|
||||
const runningTimes = $derived(runTimes.filter((t) => t.status === 'active'))
|
||||
const leaderboard = $derived(selectedRunId ? times.getLeaderboard(selectedRunId).slice(0, 5) : [])
|
||||
|
||||
function getElapsed(startISO: string | undefined): string {
|
||||
if (!startISO) return '0:00.0'
|
||||
const start = new Date(startISO).getTime()
|
||||
const elapsed = (Date.now() - start) / 1000
|
||||
const m = Math.floor(elapsed / 60)
|
||||
const s = (elapsed % 60).toFixed(1)
|
||||
return `${m}:${s.padStart(4, '0')}`
|
||||
}
|
||||
|
||||
function getRider(id: string | undefined) {
|
||||
return id ? riders.getById(id) : undefined
|
||||
}
|
||||
|
||||
async function start(riderId: string) {
|
||||
if (!selectedRunId) return alert('Bitte Run auswählen')
|
||||
if (times.isRiderActive(selectedRunId, riderId)) return alert('Dieser Fahrer läuft bereits!')
|
||||
try {
|
||||
await times.start(selectedRunId, riderId)
|
||||
} catch (e: any) {
|
||||
alert(`Start fehlgeschlagen: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(timeId: string) {
|
||||
try {
|
||||
await times.stop(timeId)
|
||||
} catch (e: any) {
|
||||
alert(`Stopp fehlgeschlagen: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedEventName = $derived(
|
||||
selectedEventId ? events.getById(selectedEventId)?.name ?? 'Event wählen…' : 'Event wählen…',
|
||||
)
|
||||
const selectedRunName = $derived(
|
||||
selectedRunId ? runs.getById(selectedRunId)?.description ?? 'Run wählen…' : 'Run wählen…',
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Zeitnahme</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Zeitnahme</h1>
|
||||
<p class="mt-1 text-muted-foreground">Zeiten für Fahrer starten und stoppen</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="py-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Event</Label>
|
||||
<Select.Root type="single" bind:value={selectedEventId}>
|
||||
<Select.Trigger>{selectedEventName}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each events.scoped as e}
|
||||
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Run</Label>
|
||||
<Select.Root type="single" bind:value={selectedRunId} disabled={availableRuns.length === 0}>
|
||||
<Select.Trigger>{selectedRunName}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each availableRuns as r}
|
||||
<Select.Item value={r.id}>{r.description}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if selectedEventId && availableRuns.length === 0}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Keine Runs für dieses Event. Über PocketBase Runs anlegen.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if !selectedEventId || !selectedRunId}
|
||||
<Card>
|
||||
<CardContent class="py-16 text-center">
|
||||
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">Wähle ein Event und einen Run</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Um mit der Zeitnahme zu beginnen, oben Event und Run auswählen.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input bind:value={searchQuery} placeholder="Fahrer suchen (Nummer, Name)…" class="pl-9" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-0">
|
||||
{#if filteredRiders().length === 0}
|
||||
<div class="py-12 text-center text-muted-foreground">
|
||||
{searchQuery ? 'Keine Treffer' : 'Keine Fahrer für dieses Event'}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y">
|
||||
{#each filteredRiders() as rider (rider.id)}
|
||||
{@const active = times.isRiderActive(selectedRunId, rider.id)}
|
||||
{@const activeTime = active ? times.getActiveTime(selectedRunId, rider.id) : undefined}
|
||||
<li class="p-4 flex items-center justify-between hover:bg-accent/40">
|
||||
<div class="flex items-center gap-4">
|
||||
<Badge variant="outline" class="font-mono text-base px-3 py-1">
|
||||
{rider.number || '—'}
|
||||
</Badge>
|
||||
<div>
|
||||
<p class="font-medium">{fullName(rider) || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if active && activeTime}
|
||||
{#key tick}
|
||||
<div class="font-mono text-2xl font-semibold tabular-nums">
|
||||
{getElapsed(activeTime.start)}
|
||||
</div>
|
||||
{/key}
|
||||
<Button variant="destructive" onclick={() => stop(activeTime.id)}>
|
||||
<Square class="h-4 w-4 mr-2" />
|
||||
Stopp
|
||||
</Button>
|
||||
{:else}
|
||||
<Button onclick={() => start(rider.id)}>
|
||||
<Play class="h-4 w-4 mr-2" />
|
||||
Start
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Clock class="h-4 w-4 text-primary" />
|
||||
Laufende Zeiten ({runningTimes.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
{#if runningTimes.length === 0}
|
||||
<p class="px-4 py-6 text-sm text-center text-muted-foreground">Keine laufenden Zeiten</p>
|
||||
{:else}
|
||||
<ul class="divide-y">
|
||||
{#each runningTimes as t}
|
||||
{@const r = getRider(t.rider)}
|
||||
{#if r}
|
||||
{#key tick}
|
||||
<li class="px-4 py-3">
|
||||
<p class="text-sm text-muted-foreground">#{r.number} {fullName(r)}</p>
|
||||
<p class="font-mono text-xl font-semibold tabular-nums">{getElapsed(t.start)}</p>
|
||||
</li>
|
||||
{/key}
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Trophy class="h-4 w-4 text-primary" />
|
||||
Top 5
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
{#if leaderboard.length === 0}
|
||||
<p class="px-4 py-6 text-sm text-center text-muted-foreground">Noch keine Ergebnisse</p>
|
||||
{:else}
|
||||
<ol class="divide-y">
|
||||
{#each leaderboard as t, i}
|
||||
{@const r = getRider(t.rider)}
|
||||
{#if r}
|
||||
<li class="px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant={i < 3 ? 'default' : 'secondary'} class="font-mono w-6 justify-center">
|
||||
{i + 1}
|
||||
</Badge>
|
||||
<span class="text-sm">#{r.number} {fullName(r)}</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm font-semibold tabular-nums">{t.formattedTime}</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
||||
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||
import { trailStatus } from '$lib/trailStatus'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -22,18 +23,6 @@
|
|||
let saving = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const STATUS_LABEL = {
|
||||
offen: 'Offen',
|
||||
eingeschraenkt: 'Eingeschränkt',
|
||||
gesperrt: 'Gesperrt',
|
||||
} as const
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
offen: 'default',
|
||||
eingeschraenkt: 'secondary',
|
||||
gesperrt: 'destructive',
|
||||
} as const
|
||||
|
||||
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
|
||||
|
||||
/** Aktive Version eines Trails, für Kennzahlen und Kartenlinie */
|
||||
|
|
@ -119,14 +108,19 @@
|
|||
{#each trails.scoped as trail (trail.id)}
|
||||
{@const version = currentVersion(trail.id)}
|
||||
{@const open = markers.openByTrail(trail.id).length}
|
||||
{@const status = trailStatus(trail.status)}
|
||||
<a href="/dashboard/trails/{trail.id}" class="block">
|
||||
<Card class="h-full hover:border-primary transition-colors">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle class="text-base">{trail.name}</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
|
||||
{STATUS_LABEL[trail.status] ?? trail.status}
|
||||
</Badge>
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
|
||||
style="color: {status.color}; border-color: {status.color}"
|
||||
>
|
||||
<span class="size-1.5 rounded-full" style:background={status.color}></span>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
|
|
@ -135,6 +129,7 @@
|
|||
geojson={version.geojson}
|
||||
bounds={version.bounds}
|
||||
status={trail.status}
|
||||
controls={false}
|
||||
height="140px"
|
||||
/>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -9,21 +9,28 @@
|
|||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
|
||||
import FlagIcon from '$lib/components/FlagIcon.svelte'
|
||||
import IconPicker from '$lib/components/IconPicker.svelte'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import { tooltip } from '$lib/stores/app.svelte'
|
||||
import { positionAtDistance } from '$lib/gpx'
|
||||
import { app } from '$lib/stores/app.svelte'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, 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 * as DropdownMenu from '@/components/ui/dropdown-menu'
|
||||
import * as Select from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
|
||||
import {
|
||||
ArrowLeft, Upload, MapPin, MessageSquare, History,
|
||||
Check, Trash2, Download, Plus, AlertTriangle,
|
||||
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
|
||||
Check, Trash2, Download, Plus, Ruler, TrendingUp,
|
||||
} from 'lucide-svelte'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { browser } from '$app/environment'
|
||||
import type { TrailCommentsResponse, TrailVersionsResponse } from '$lib/types'
|
||||
import type { TrailCommentsResponse, TrailVersionsResponse, UsersResponse } from '$lib/types'
|
||||
|
||||
// Generics der JSON-Felder, wie in trails/+page.svelte — die
|
||||
// Rohdaten kommen ungetypt aus PocketBase zurück.
|
||||
|
|
@ -65,23 +72,54 @@
|
|||
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
|
||||
const trailMarkers = $derived(trail ? markers.byTrail(trail.id) : [])
|
||||
|
||||
// Für die Karte aufbereitete Marker
|
||||
const NEUTRAL = '#64748b'
|
||||
|
||||
/**
|
||||
* Beschriftung, Icon und Farbe eines Markers. Ein eigener Text oder ein
|
||||
* eigenes Icon am Marker geht vor: Beim Setzen darf der Typ nur den
|
||||
* Vorschlag liefern, nicht das letzte Wort haben.
|
||||
*/
|
||||
function markerLook(m: { flag?: string; label?: string; icon?: string }) {
|
||||
const flag = m.flag ? flags.getById(m.flag) : undefined
|
||||
|
||||
return {
|
||||
label: m.label?.trim() || flag?.label || 'Marker',
|
||||
icon: m.icon?.trim() || flag?.icon,
|
||||
color: flag?.color ?? NEUTRAL,
|
||||
}
|
||||
}
|
||||
|
||||
// Für die Karte aufbereitete Marker — inklusive der Angaben fürs Popup
|
||||
// (was, wann, wer). Formatiert wird hier, damit die Karte keine Stores
|
||||
// und keine Datumslogik braucht.
|
||||
const mapMarkers = $derived(
|
||||
trailMarkers.map((m) => {
|
||||
const flag = flags.getById(m.flag)
|
||||
const look = markerLook(m)
|
||||
return {
|
||||
id: m.id,
|
||||
lat: m.lat,
|
||||
lng: m.lng,
|
||||
color: flag?.color ?? '#64748b',
|
||||
color: look.color,
|
||||
resolved: !!m.resolved,
|
||||
label: flag?.label ?? 'Marker',
|
||||
label: look.label,
|
||||
icon: look.icon,
|
||||
note: m.note,
|
||||
when: formatDate(m.created),
|
||||
who: markers.authorName(m),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
let tab = $state<'marker' | 'kommentare' | 'versionen'>('marker')
|
||||
let highlight = $state<number | null>(null)
|
||||
// Versionen sind Archiv, kein Alltag — deshalb zugeklappt.
|
||||
let showVersions = $state(false)
|
||||
|
||||
const statusInfo = $derived(trailStatus(trail?.status))
|
||||
|
||||
// Wer den Status gemeldet hat, steht nur als ID im Datensatz.
|
||||
$effect(() => {
|
||||
if (trail?.status_by) teams.loadUser(trail.status_by)
|
||||
})
|
||||
|
||||
// --- GPX-Upload ------------------------------------------------------
|
||||
let uploadDialog = $state(false)
|
||||
|
|
@ -112,26 +150,88 @@
|
|||
}
|
||||
|
||||
// --- Marker setzen ---------------------------------------------------
|
||||
let placing = $state(false)
|
||||
// Die Koordinaten kommen bereits auf die Linie projiziert aus der Karte;
|
||||
// abseits des Trails lässt sich gar nicht erst klicken.
|
||||
let markerDialog = $state(false)
|
||||
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
|
||||
let markerFlag = $state('')
|
||||
let markerDistance = $state<number | null>(null)
|
||||
// Leerer Wert = eigener Typ. Als erste Zeile im Dropdown, damit eine
|
||||
// Meldung nie daran scheitert, dass es den passenden Typ noch nicht gibt.
|
||||
const CUSTOM = ''
|
||||
|
||||
let markerFlag = $state(CUSTOM)
|
||||
let markerLabel = $state('')
|
||||
let markerIcon = $state('')
|
||||
let markerNote = $state('')
|
||||
let markerError = $state<string | null>(null)
|
||||
|
||||
function onPlace(coords: { lat: number; lng: number }) {
|
||||
markerCoords = coords
|
||||
markerFlag = flags.scoped[0]?.id ?? ''
|
||||
// Ohne Flag-Typen geht es trotzdem: Dann ist jede Meldung eine eigene.
|
||||
const canPlace = $derived(!!trail)
|
||||
|
||||
const selectedFlag = $derived(markerFlag ? flags.getById(markerFlag) : undefined)
|
||||
const markerColor = $derived(selectedFlag?.color ?? NEUTRAL)
|
||||
|
||||
function onPlace(p: { lat: number; lng: number; distance: number | null }) {
|
||||
if (!canPlace) return
|
||||
|
||||
markerCoords = { lat: p.lat, lng: p.lng }
|
||||
markerDistance = p.distance
|
||||
markerFlag = flags.scoped[0]?.id ?? CUSTOM
|
||||
markerLabel = ''
|
||||
markerIcon = flags.scoped[0]?.icon ?? ''
|
||||
markerNote = ''
|
||||
markerError = null
|
||||
placing = false
|
||||
markerDialog = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Typwechsel übernimmt dessen Icon — als Vorschlag. Wer danach ein anderes
|
||||
* wählt, behält es; erst der nächste Typwechsel schlägt wieder etwas vor.
|
||||
*/
|
||||
function selectFlag(id: string) {
|
||||
markerFlag = id
|
||||
markerIcon = id ? (flags.getById(id)?.icon ?? '') : ''
|
||||
}
|
||||
|
||||
function removeMarker(m: { id: string; note?: string }) {
|
||||
app.confirm.request({
|
||||
title: 'Marker löschen?',
|
||||
text: m.note
|
||||
? `„${m.note}" wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.`
|
||||
: 'Der Marker wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.',
|
||||
yes: async () => {
|
||||
try {
|
||||
await markers.remove(m.id)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Der Marker konnte nicht gelöscht werden.')
|
||||
} finally {
|
||||
app.confirm.close()
|
||||
}
|
||||
},
|
||||
no: () => app.confirm.close(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker aus dem Höhenprofil heraus. Die Stelle kommt als Streckenkilometer
|
||||
* und wird auf dieselbe Linie zurückgerechnet, die auch ein Klick auf der
|
||||
* Karte trifft — beide Wege landen deshalb exakt auf dem Trail.
|
||||
*/
|
||||
function placeFromProfile(distance: number) {
|
||||
const coords = version?.geojson?.coordinates
|
||||
if (!coords?.length) return
|
||||
|
||||
const at = positionAtDistance(coords, version?.coord_distances ?? [], distance)
|
||||
if (!at) return
|
||||
|
||||
onPlace({ lng: at[0], lat: at[1], distance })
|
||||
}
|
||||
|
||||
async function saveMarker() {
|
||||
if (!trail || !markerCoords) return
|
||||
if (!markerFlag) {
|
||||
markerError = 'Bitte einen Flag-Typ wählen.'
|
||||
|
||||
if (!markerFlag && !markerLabel.trim()) {
|
||||
markerError = 'Bitte einen Typ wählen oder eine eigene Bezeichnung eintragen.'
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -139,12 +239,17 @@
|
|||
await markers.create({
|
||||
trail: trail.id,
|
||||
flag: markerFlag,
|
||||
// Bei einem vorgefertigten Typ steht die Bezeichnung schon dort;
|
||||
// sie hier zu kopieren hieße, sie zweimal zu pflegen.
|
||||
label: markerFlag ? '' : markerLabel.trim(),
|
||||
icon: markerIcon.trim(),
|
||||
lat: markerCoords.lat,
|
||||
lng: markerCoords.lng,
|
||||
note: markerNote,
|
||||
})
|
||||
markerDialog = false
|
||||
markerCoords = null
|
||||
markerDistance = null
|
||||
} catch (e: any) {
|
||||
markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.'
|
||||
}
|
||||
|
|
@ -152,7 +257,9 @@
|
|||
|
||||
// --- Kommentare ------------------------------------------------------
|
||||
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
|
||||
let comments = $state<TrailCommentsResponse[]>([])
|
||||
type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }>
|
||||
|
||||
let comments = $state<Comment[]>([])
|
||||
let commentText = $state('')
|
||||
let commentError = $state<string | null>(null)
|
||||
let unsubComments: (() => void) | null = null
|
||||
|
|
@ -164,20 +271,30 @@
|
|||
let cancelled = false
|
||||
|
||||
api.collection('trail_comments')
|
||||
.getFullList({ filter: `trail="${id}"`, sort: '-created', requestKey: null })
|
||||
.getFullList({
|
||||
filter: `trail="${id}"`,
|
||||
sort: '-created',
|
||||
expand: 'created_by',
|
||||
requestKey: null,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) comments = r
|
||||
if (!cancelled) comments = r as Comment[]
|
||||
})
|
||||
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
|
||||
|
||||
api.collection('trail_comments')
|
||||
.subscribe('*', (e) => {
|
||||
if (e.record.trail !== id) return
|
||||
const idx = comments.findIndex((c) => c.id === e.record.id)
|
||||
if (e.action === 'create' && idx === -1) comments = [e.record, ...comments]
|
||||
else if (e.action === 'update' && idx !== -1) comments[idx] = e.record
|
||||
else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== e.record.id)
|
||||
})
|
||||
.subscribe(
|
||||
'*',
|
||||
(e) => {
|
||||
if (e.record.trail !== id) return
|
||||
const record = e.record as Comment
|
||||
const idx = comments.findIndex((c) => c.id === record.id)
|
||||
if (e.action === 'create' && idx === -1) comments = [record, ...comments]
|
||||
else if (e.action === 'update' && idx !== -1) comments[idx] = record
|
||||
else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== record.id)
|
||||
},
|
||||
{ expand: 'created_by' },
|
||||
)
|
||||
.then((u) => {
|
||||
if (cancelled) u()
|
||||
else unsubComments = u
|
||||
|
|
@ -209,25 +326,18 @@
|
|||
}
|
||||
|
||||
// --- Hilfsfunktionen -------------------------------------------------
|
||||
const STATUS_LABEL = {
|
||||
offen: 'Offen',
|
||||
eingeschraenkt: 'Eingeschränkt',
|
||||
gesperrt: 'Gesperrt',
|
||||
} as const
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
offen: 'default',
|
||||
eingeschraenkt: 'secondary',
|
||||
gesperrt: 'destructive',
|
||||
} as const
|
||||
|
||||
function formatDate(s: string) {
|
||||
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
async function setStatus(status: string) {
|
||||
if (!trail) return
|
||||
await trails.edit(trail.id, { status } as any)
|
||||
if (!trail || status === trail.status) return
|
||||
|
||||
try {
|
||||
await trails.setStatus(trail.id, status)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Der Zustand konnte nicht gemeldet werden.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -242,251 +352,306 @@
|
|||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Kopf -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
|
||||
<ArrowLeft class="size-4 mr-2" />
|
||||
Trails
|
||||
</Button>
|
||||
<h1 class="text-2xl font-semibold">{trail.name}</h1>
|
||||
{#if safeDescription}
|
||||
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
|
||||
<ArrowLeft class="size-4 mr-2" />
|
||||
Trails
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<h1 class="text-4xl font-bold tracking-tight">{trail.name}</h1>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
|
||||
{STATUS_LABEL[trail.status] ?? trail.status}
|
||||
</Badge>
|
||||
{#if canEdit}
|
||||
<Button variant="outline" size="sm" onclick={() => (uploadDialog = true)}>
|
||||
<Upload class="size-4 mr-2" />
|
||||
GPX hochladen
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if canEdit}
|
||||
<div class="flex gap-2">
|
||||
{#each ['offen', 'eingeschraenkt', 'gesperrt'] as s (s)}
|
||||
<Button
|
||||
variant={trail.status === s ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setStatus(s)}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<!-- Farbe kommt aus derselben Quelle wie die Linie auf der Karte. -->
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="gap-2"
|
||||
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
|
||||
>
|
||||
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
|
||||
{statusInfo.label}
|
||||
<ChevronDown class="size-3 opacity-60" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="start" class="min-w-60">
|
||||
<DropdownMenu.Label>Zustand melden</DropdownMenu.Label>
|
||||
{#each TRAIL_STATUS as s (s.value)}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => setStatus(s.value)}
|
||||
class={s.value === trail.status ? 'bg-accent' : ''}
|
||||
>
|
||||
<span class="size-2 rounded-full mr-2 shrink-0" style:background={s.color}></span>
|
||||
<span class="flex-1">{s.label}</span>
|
||||
<span class="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm"
|
||||
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
|
||||
>
|
||||
{STATUS_LABEL[s as keyof typeof STATUS_LABEL]}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Karte -->
|
||||
<Card>
|
||||
<CardContent class="pt-6 space-y-4">
|
||||
{#if version?.geojson}
|
||||
<TrailMap
|
||||
geojson={version.geojson}
|
||||
bounds={version.bounds}
|
||||
markers={mapMarkers}
|
||||
status={trail.status}
|
||||
{placing}
|
||||
coordDistances={version.coord_distances ?? []}
|
||||
highlightDistance={highlight}
|
||||
onplace={onPlace}
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-6 text-sm">
|
||||
<span><strong>{(version.distance_m / 1000).toFixed(1)}</strong> km</span>
|
||||
<span><strong>{version.ascent_m}</strong> hm</span>
|
||||
<Button
|
||||
variant={placing ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
class="ml-auto"
|
||||
onclick={() => (placing = !placing)}
|
||||
disabled={flags.scoped.length === 0}
|
||||
>
|
||||
<MapPin class="size-4 mr-2" />
|
||||
{placing ? 'Abbrechen' : 'Marker setzen'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if flags.scoped.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Es gibt noch keine Flag-Typen.
|
||||
<a href="/dashboard/settings/flags" class="underline">Jetzt anlegen</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if version.elevation?.length}
|
||||
<Separator />
|
||||
<ElevationProfile
|
||||
elevation={version.elevation}
|
||||
onhover={(i) => (highlight = i)}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="py-12 text-center space-y-3">
|
||||
<Upload class="size-10 mx-auto text-muted-foreground" />
|
||||
<p class="text-muted-foreground">Für diesen Trail gibt es noch keine GPX-Datei.</p>
|
||||
{#if canEdit}
|
||||
<Button onclick={() => (uploadDialog = true)}>
|
||||
<Upload class="size-4 mr-2" />
|
||||
GPX hochladen
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Reiter -->
|
||||
<div class="flex gap-2 border-b">
|
||||
<button
|
||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
||||
class:border-primary={tab === 'marker'}
|
||||
class:border-transparent={tab !== 'marker'}
|
||||
onclick={() => (tab = 'marker')}
|
||||
>
|
||||
<MapPin class="size-4 inline mr-1" />
|
||||
Marker ({trailMarkers.length})
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
||||
class:border-primary={tab === 'kommentare'}
|
||||
class:border-transparent={tab !== 'kommentare'}
|
||||
onclick={() => (tab = 'kommentare')}
|
||||
>
|
||||
<MessageSquare class="size-4 inline mr-1" />
|
||||
Kommentare ({comments.length})
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
||||
class:border-primary={tab === 'versionen'}
|
||||
class:border-transparent={tab !== 'versionen'}
|
||||
onclick={() => (tab = 'versionen')}
|
||||
>
|
||||
<History class="size-4 inline mr-1" />
|
||||
Versionen ({trailVersions.length})
|
||||
</button>
|
||||
{#if version}
|
||||
<span class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Ruler class="size-4" />
|
||||
<strong class="text-foreground">{(version.distance_m / 1000).toFixed(1)}</strong> km
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<TrendingUp class="size-4" />
|
||||
<strong class="text-foreground">{version.ascent_m}</strong> hm
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if trail.status_changed}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
|
||||
gemeldet am {formatDate(trail.status_changed)}
|
||||
{#if trail.status_by}
|
||||
von {teams.userLabel(trail.status_by)}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if safeDescription}
|
||||
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if tab === 'marker'}
|
||||
{#if trailMarkers.length === 0}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Noch keine Marker. Setze einen über „Marker setzen" auf der Karte.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each trailMarkers as m (m.id)}
|
||||
{@const flag = flags.getById(m.flag)}
|
||||
<Card class={m.resolved ? 'opacity-60' : ''}>
|
||||
<CardContent class="py-3 flex items-center gap-3">
|
||||
<span
|
||||
class="size-3 rounded-full shrink-0"
|
||||
style:background={flag?.color ?? '#64748b'}
|
||||
></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
|
||||
{#if m.note}
|
||||
<p class="text-sm text-muted-foreground">{m.note}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">{formatDate(m.created)}</p>
|
||||
</div>
|
||||
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||
onclick={async () => {
|
||||
try {
|
||||
await markers.toggleResolved(m.id)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if tab === 'kommentare'}
|
||||
<div class="grid gap-6 lg:grid-cols-3 items-start">
|
||||
<!-- Karte und Höhenprofil -->
|
||||
<Card class="lg:col-span-2">
|
||||
<CardContent class="pt-6 space-y-4">
|
||||
{#if version?.geojson}
|
||||
<TrailMap
|
||||
geojson={version.geojson}
|
||||
bounds={version.bounds}
|
||||
markers={mapMarkers}
|
||||
status={trail.status}
|
||||
coordDistances={version.coord_distances ?? []}
|
||||
highlightDistance={highlight}
|
||||
onplace={canPlace ? onPlace : undefined}
|
||||
ontrailhover={(d) => (highlight = d)}
|
||||
/>
|
||||
|
||||
{#if version.elevation?.length}
|
||||
<ElevationProfile
|
||||
elevation={version.elevation}
|
||||
highlightDistance={highlight}
|
||||
onhover={(d) => (highlight = d)}
|
||||
onselect={canPlace ? placeFromProfile : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="py-12 text-center space-y-3">
|
||||
<Upload class="size-10 mx-auto text-muted-foreground" />
|
||||
<p class="text-muted-foreground">Für diesen Trail gibt es noch keine GPX-Datei.</p>
|
||||
{#if canEdit}
|
||||
<Button onclick={() => (uploadDialog = true)}>
|
||||
<Upload class="size-4 mr-2" />
|
||||
GPX hochladen
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Marker, Kommentare, Versionen -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={commentText}
|
||||
placeholder="Kommentar schreiben …"
|
||||
onkeydown={(e) => e.key === 'Enter' && addComment()}
|
||||
/>
|
||||
<Button onclick={addComment} disabled={!commentText.trim()}>
|
||||
<Plus class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{#if commentError}
|
||||
<p class="text-sm text-destructive">{commentError}</p>
|
||||
{/if}
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<MapPin class="size-4 text-primary" />
|
||||
Marker ({trailMarkers.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
|
||||
Marker zu setzen.
|
||||
</p>
|
||||
|
||||
{#if comments.length === 0}
|
||||
<p class="text-muted-foreground text-sm">Noch keine Kommentare.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each comments as c (c.id)}
|
||||
<Card>
|
||||
<CardContent class="py-3">
|
||||
<p class="text-sm">{c.text}</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if trailVersions.length === 0}
|
||||
<p class="text-muted-foreground text-sm">Noch keine Version hochgeladen.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each trailVersions as v (v.id)}
|
||||
<Card class={v.id === trail.current ? 'border-primary' : ''}>
|
||||
<CardContent class="py-3 flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">{formatDate(v.created)}</p>
|
||||
{#if v.id === trail.current}
|
||||
<Badge>Aktiv</Badge>
|
||||
{#if trailMarkers.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
|
||||
{:else}
|
||||
<ul class="divide-y -mx-2">
|
||||
{#each trailMarkers as m (m.id)}
|
||||
{@const look = markerLook(m)}
|
||||
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
|
||||
<FlagIcon name={look.icon} color={look.color} class="size-5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{look.label}</p>
|
||||
{#if m.note}
|
||||
<p class="text-sm text-muted-foreground">{m.note}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{formatDate(m.created)} · {markers.authorName(m)}
|
||||
</p>
|
||||
</div>
|
||||
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
|
||||
<div class="flex shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||
onclick={async () => {
|
||||
try {
|
||||
await markers.toggleResolved(m.id)
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
title="Marker löschen"
|
||||
onclick={() => removeMarker(m)}
|
||||
>
|
||||
<Trash2 class="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
|
||||
{#if v.note}· {v.note}{/if}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if v.gpx}
|
||||
<Button variant="ghost" size="sm" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
|
||||
<Download class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<MessageSquare class="size-4 text-primary" />
|
||||
Kommentare ({comments.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={commentText}
|
||||
placeholder="Kommentar schreiben …"
|
||||
onkeydown={(e) => e.key === 'Enter' && addComment()}
|
||||
/>
|
||||
<Button onclick={addComment} disabled={!commentText.trim()}>
|
||||
<Plus class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{#if commentError}
|
||||
<p class="text-sm text-destructive">{commentError}</p>
|
||||
{/if}
|
||||
|
||||
{#if canEdit && v.id !== trail.current}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => versions.activate(trail.id, v.id)}
|
||||
>
|
||||
Aktivieren
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if comments.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Noch keine Kommentare.</p>
|
||||
{:else}
|
||||
<ul class="divide-y -mx-2">
|
||||
{#each comments as c (c.id)}
|
||||
{@const author = c.expand?.created_by}
|
||||
<li class="flex gap-3 px-2 py-3">
|
||||
<!-- Nur das Bild; wer dahintersteckt, sagt der Tooltip. -->
|
||||
<span
|
||||
use:tooltip={{
|
||||
content: author?.name || author?.email || 'Unbekannt',
|
||||
}}
|
||||
>
|
||||
<Avatar user={author} size={28} />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm">{c.text}</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<!-- Versionen sind Archiv: zugeklappt, bis jemand sie braucht. -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-6 py-4 text-left"
|
||||
aria-expanded={showVersions}
|
||||
onclick={() => (showVersions = !showVersions)}
|
||||
>
|
||||
<History class="size-4 text-primary" />
|
||||
<span class="font-semibold text-base flex-1">
|
||||
Versionen ({trailVersions.length})
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="size-4 text-muted-foreground transition-transform"
|
||||
style={showVersions ? 'transform: rotate(180deg)' : ''}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if showVersions}
|
||||
<CardContent class="pt-0 space-y-3">
|
||||
{#if canEdit}
|
||||
<Button variant="outline" size="sm" class="w-full" onclick={() => (uploadDialog = true)}>
|
||||
<Upload class="size-4 mr-2" />
|
||||
GPX hochladen
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if trailVersions.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Noch keine Version hochgeladen.</p>
|
||||
{:else}
|
||||
<ul class="divide-y -mx-2">
|
||||
{#each trailVersions as v (v.id)}
|
||||
<li class="px-2 py-3 flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">{formatDate(v.created)}</p>
|
||||
{#if v.id === trail.current}
|
||||
<Badge>Aktiv</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
|
||||
{#if v.note}· {v.note}{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if v.gpx}
|
||||
<Button variant="ghost" size="icon" class="size-8" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
|
||||
<Download class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if canEdit && v.id !== trail.current}
|
||||
<Button variant="outline" size="sm" onclick={() => versions.activate(trail.id, v.id)}>
|
||||
Aktivieren
|
||||
</Button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</CardContent>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -534,24 +699,47 @@
|
|||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Marker setzen</Dialog.Title>
|
||||
{#if markerDistance != null}
|
||||
<Dialog.Description>
|
||||
bei km {(markerDistance / 1000).toFixed(2)} des Trails
|
||||
</Dialog.Description>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Typ</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each flags.scoped as f (f.id)}
|
||||
<Button
|
||||
variant={markerFlag === f.id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (markerFlag = f.id)}
|
||||
>
|
||||
<span class="size-2 rounded-full mr-2" style:background={f.color}></span>
|
||||
{f.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<Select.Root type="single" value={markerFlag} onValueChange={selectFlag}>
|
||||
<Select.Trigger class="w-full">
|
||||
{#if selectedFlag}
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="size-2 rounded-full" style:background={selectedFlag.color}></span>
|
||||
{selectedFlag.label}
|
||||
</span>
|
||||
{:else}
|
||||
Eigener Typ
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={CUSTOM}>Eigener Typ …</Select.Item>
|
||||
{#each flags.scoped as f (f.id)}
|
||||
<Select.Item value={f.id}>
|
||||
<span class="size-2 rounded-full mr-2 shrink-0" style:background={f.color}></span>
|
||||
{f.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{#if !markerFlag}
|
||||
<div class="space-y-2">
|
||||
<Label for="marker-label">Bezeichnung</Label>
|
||||
<Input id="marker-label" bind:value={markerLabel} placeholder="z. B. Wespennest am Anlieger" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<IconPicker id="marker-icon" bind:value={markerIcon} color={markerColor} />
|
||||
<div class="space-y-2">
|
||||
<Label for="marker-note">Notiz</Label>
|
||||
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue