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_",
|
"id": "_pb_users_auth_",
|
||||||
"listRule": "id = @request.auth.id",
|
"listRule": "id = @request.auth.id",
|
||||||
"viewRule": "id = @request.auth.id",
|
"viewRule": "@request.auth.id != \"\"",
|
||||||
"createRule": null,
|
"createRule": "@request.auth.id != \"\"",
|
||||||
"updateRule": "id = @request.auth.id",
|
"updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false",
|
||||||
"deleteRule": "id = @request.auth.id",
|
"deleteRule": "id = @request.auth.id",
|
||||||
"name": "users",
|
"name": "users",
|
||||||
"type": "auth",
|
"type": "auth",
|
||||||
|
|
@ -125,6 +125,15 @@ migrate((app) => {
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "text"
|
"type": "text"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool_user_superadmin",
|
||||||
|
"name": "superadmin",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"help": "",
|
"help": "",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
|
|
@ -457,20 +466,26 @@ migrate((app) => {
|
||||||
"type": "text"
|
"type": "text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"help": "",
|
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "select2063623452",
|
"id": "date_event_starts",
|
||||||
"maxSelect": 1,
|
"max": "",
|
||||||
"name": "status",
|
"min": "",
|
||||||
|
"name": "starts",
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"required": false,
|
"required": false,
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "select",
|
"type": "date"
|
||||||
"values": [
|
},
|
||||||
"draft",
|
{
|
||||||
"active",
|
"hidden": false,
|
||||||
"finished"
|
"id": "date_event_ends",
|
||||||
]
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "ends",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cascadeDelete": false,
|
"cascadeDelete": false,
|
||||||
|
|
@ -590,9 +605,9 @@ migrate((app) => {
|
||||||
"id": "spgs7e4anufen9u",
|
"id": "spgs7e4anufen9u",
|
||||||
"listRule": "team.users.id ?= @request.auth.id",
|
"listRule": "team.users.id ?= @request.auth.id",
|
||||||
"viewRule": "team.users.id ?= @request.auth.id",
|
"viewRule": "team.users.id ?= @request.auth.id",
|
||||||
"createRule": "team.users.id ?= @request.auth.id",
|
"createRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
|
||||||
"updateRule": "team.users.id ?= @request.auth.id",
|
"updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
|
||||||
"deleteRule": "team.users.id ?= @request.auth.id",
|
"deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
|
||||||
"name": "riders",
|
"name": "riders",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"fields": [
|
"fields": [
|
||||||
|
|
@ -655,6 +670,20 @@ migrate((app) => {
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "relation"
|
"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,
|
"hidden": false,
|
||||||
"id": "autodate2990389176",
|
"id": "autodate2990389176",
|
||||||
|
|
@ -1013,6 +1042,31 @@ migrate((app) => {
|
||||||
"gesperrt"
|
"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,
|
"cascadeDelete": false,
|
||||||
"collectionId": "_pb_users_auth_",
|
"collectionId": "_pb_users_auth_",
|
||||||
|
|
@ -1467,10 +1521,40 @@ migrate((app) => {
|
||||||
"minSelect": 0,
|
"minSelect": 0,
|
||||||
"name": "flag",
|
"name": "flag",
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"required": true,
|
"required": false,
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "relation"
|
"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": "",
|
"help": "",
|
||||||
"hidden": false,
|
"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">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente die
|
* Höhenprofil als SVG-Fläche. Die hervorgehobene Stelle kommt als Prop
|
||||||
* Distanz des Punktes; die Elternkomponente reicht sie an die Karte
|
* herein und geht beim Überfahren als Callback hinaus — die Elternseite
|
||||||
* weiter, damit dort dieselbe Stelle hervorgehoben wird. Distanz statt
|
* hält den Wert und reicht ihn ebenso an die Karte weiter. So zeigen
|
||||||
* Index, weil die Karte eine andere (vereinfachte) Punktreihe zeigt.
|
* 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 {
|
let {
|
||||||
elevation,
|
elevation,
|
||||||
onhover,
|
onhover,
|
||||||
|
onselect,
|
||||||
|
highlightDistance = null,
|
||||||
height = '120px',
|
height = '120px',
|
||||||
}: {
|
}: {
|
||||||
elevation: { d: number; ele: number }[]
|
elevation: { d: number; ele: number }[]
|
||||||
onhover?: (distance: number | null) => void
|
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
|
height?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
|
|
@ -46,42 +57,148 @@
|
||||||
return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z`
|
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) {
|
let lo = 0
|
||||||
if (!elevation.length) return
|
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 ratio = (e.clientX - rect.left) / rect.width
|
||||||
const idx = Math.round(ratio * (elevation.length - 1))
|
const idx = Math.round(ratio * (elevation.length - 1))
|
||||||
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
|
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
|
||||||
|
|
||||||
hoverX = ratio * W
|
return elevation[clamped].d
|
||||||
onhover?.(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() {
|
function handleLeave() {
|
||||||
hoverX = null
|
|
||||||
onhover?.(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>
|
</script>
|
||||||
|
|
||||||
{#if stats}
|
{#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
|
<svg
|
||||||
viewBox="0 0 {W} {H}"
|
viewBox="0 0 {W} {H}"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
class="w-full h-full"
|
class="w-full h-full"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Höhenprofil"
|
aria-hidden="true"
|
||||||
onmousemove={handleMove}
|
|
||||||
onmouseleave={handleLeave}
|
|
||||||
>
|
>
|
||||||
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
|
<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}
|
{#if hover}
|
||||||
<line x1={hoverX} y1="0" x2={hoverX} y2={H} stroke="rgb(14 165 233)" stroke-width="1" vector-effect="non-scaling-stroke" />
|
<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}
|
{/if}
|
||||||
</svg>
|
</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>
|
||||||
<div class="flex justify-between text-xs text-muted-foreground mt-1">
|
<div class="flex justify-between text-xs text-muted-foreground mt-1">
|
||||||
<span>{Math.round(stats.min)} m</span>
|
<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>
|
</DropdownMenu.Item>
|
||||||
{/each}
|
{/each}
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item onclick={() => goto('/dashboard/teams')}>
|
<DropdownMenu.Item onclick={() => goto('/dashboard/settings/team')}>
|
||||||
<Users class="h-4 w-4 mr-2" />
|
<Users class="h-4 w-4 mr-2" />
|
||||||
Teams verwalten
|
Teams verwalten
|
||||||
</DropdownMenu.Item>
|
</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
|
* Props, Ereignisse gehen über Callback-Props zurück. Damit ist die
|
||||||
* Komponente in Liste, Detailseite und Setzmodus gleichermaßen nutzbar.
|
* 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 * 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'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
// MapLibre 6 liegt als drei Dateien vor (Hauptdatei, shared, worker) und
|
// MapLibre 6 liegt als drei Dateien vor (Hauptdatei, shared, worker) und
|
||||||
// lädt den Worker zur Laufzeit relativ zum eigenen Verzeichnis nach. Beides
|
// 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
|
// Datei — ein blosses ?url würde nur die Rohdatei kopieren und ihren
|
||||||
// relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404).
|
// relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404).
|
||||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
|
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)
|
maplibregl.setWorkerUrl(workerUrl)
|
||||||
|
|
||||||
|
|
@ -32,7 +37,15 @@
|
||||||
lng: number
|
lng: number
|
||||||
color: string
|
color: string
|
||||||
resolved: boolean
|
resolved: boolean
|
||||||
|
/** Was — der Flag-Typ */
|
||||||
label: string
|
label: string
|
||||||
|
/** Icon-Name des Flag-Typs */
|
||||||
|
icon?: string
|
||||||
|
/** Wann — bereits formatiert */
|
||||||
|
when?: string
|
||||||
|
/** Wer — bereits aufgelöst */
|
||||||
|
who?: string
|
||||||
|
note?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -40,44 +53,89 @@
|
||||||
bounds = null,
|
bounds = null,
|
||||||
markers = [],
|
markers = [],
|
||||||
status = 'offen',
|
status = 'offen',
|
||||||
placing = false,
|
|
||||||
coordDistances = [],
|
coordDistances = [],
|
||||||
highlightDistance = null,
|
highlightDistance = null,
|
||||||
onplace,
|
onplace,
|
||||||
onmarkerclick,
|
ontrailhover,
|
||||||
|
controls = true,
|
||||||
height = '420px',
|
height = '420px',
|
||||||
}: {
|
}: {
|
||||||
geojson?: LineGeoJSON | null
|
geojson?: LineGeoJSON | null
|
||||||
bounds?: [[number, number], [number, number]] | null
|
bounds?: [[number, number], [number, number]] | null
|
||||||
markers?: MarkerData[]
|
markers?: MarkerData[]
|
||||||
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
|
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
|
||||||
placing?: boolean
|
|
||||||
coordDistances?: number[]
|
coordDistances?: number[]
|
||||||
highlightDistance?: number | null
|
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
|
height?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
// Kachelquelle als Konstante, damit sie sich später leicht gegen eine
|
/**
|
||||||
// Outdoor-Karte mit Höhenlinien tauschen lässt.
|
* Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen
|
||||||
const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
* keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt
|
||||||
const ATTRIBUTION = '© OpenStreetMap-Mitwirkende'
|
* 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.
|
// Fallback, wenn ein Trail noch keine Version hat: Hersbruck.
|
||||||
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
|
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
|
||||||
|
|
||||||
const LINE_COLORS = {
|
|
||||||
offen: '#16a34a',
|
|
||||||
eingeschraenkt: '#ca8a04',
|
|
||||||
gesperrt: '#dc2626',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
let container: HTMLDivElement
|
let container: HTMLDivElement
|
||||||
let map = $state<MapLibreMap | null>(null)
|
let map = $state<MapLibreMap | null>(null)
|
||||||
let ready = $state(false)
|
let ready = $state(false)
|
||||||
let markerObjects: Marker[] = []
|
let markerObjects: Marker[] = []
|
||||||
let highlightMarker: Marker | null = null
|
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(() => {
|
onMount(() => {
|
||||||
let cleanupMap: (() => void) | null = null
|
let cleanupMap: (() => void) | null = null
|
||||||
|
|
@ -90,7 +148,7 @@
|
||||||
sources: {
|
sources: {
|
||||||
osm: {
|
osm: {
|
||||||
type: 'raster',
|
type: 'raster',
|
||||||
tiles: [TILES],
|
tiles: isDark ? TILES_DARK : TILES_LIGHT,
|
||||||
tileSize: 256,
|
tileSize: 256,
|
||||||
attribution: ATTRIBUTION,
|
attribution: ATTRIBUTION,
|
||||||
},
|
},
|
||||||
|
|
@ -99,20 +157,19 @@
|
||||||
},
|
},
|
||||||
center: FALLBACK_CENTER,
|
center: FALLBACK_CENTER,
|
||||||
zoom: 11,
|
zoom: 11,
|
||||||
|
attributionControl: controls ? undefined : false,
|
||||||
})
|
})
|
||||||
map = instance
|
map = instance
|
||||||
|
|
||||||
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
|
if (controls) {
|
||||||
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
|
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||||
|
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
|
||||||
|
}
|
||||||
|
|
||||||
instance.on('load', () => {
|
instance.on('load', () => {
|
||||||
ready = true
|
ready = true
|
||||||
})
|
})
|
||||||
|
|
||||||
instance.on('click', (e) => {
|
|
||||||
if (placing) onplace?.({ lat: e.lngLat.lat, lng: e.lngLat.lng })
|
|
||||||
})
|
|
||||||
|
|
||||||
cleanupMap = () => {
|
cleanupMap = () => {
|
||||||
// Map.remove() räumt Controls auf, aber keine Marker — die tragen
|
// Map.remove() räumt Controls auf, aber keine Marker — die tragen
|
||||||
// eigene move-Listener auf der Karte und werden hier zuerst entfernt.
|
// eigene move-Listener auf der Karte und werden hier zuerst entfernt.
|
||||||
|
|
@ -120,6 +177,9 @@
|
||||||
markerObjects = []
|
markerObjects = []
|
||||||
highlightMarker?.remove()
|
highlightMarker?.remove()
|
||||||
highlightMarker = null
|
highlightMarker = null
|
||||||
|
popup?.remove()
|
||||||
|
popup = null
|
||||||
|
clearIcons()
|
||||||
|
|
||||||
instance.remove()
|
instance.remove()
|
||||||
map = null
|
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
|
// Trail-Linie zeichnen bzw. aktualisieren
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!map || !ready) return
|
if (!map || !ready) return
|
||||||
|
|
||||||
const data = geojson
|
const data = geojson
|
||||||
const color = LINE_COLORS[status] ?? LINE_COLORS.offen
|
const color = trailStatusColor(status)
|
||||||
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
|
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
|
@ -175,22 +281,83 @@
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
paint: { 'line-color': color, 'line-width': 4 },
|
paint: { 'line-color': color, 'line-width': 4 },
|
||||||
})
|
})
|
||||||
|
map.addLayer({
|
||||||
|
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
|
// Kartenausschnitt auf den Track setzen
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!map || !ready || !bounds) return
|
if (!map || !ready || !bounds) return
|
||||||
map.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 16 })
|
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
|
// Marker neu aufbauen — bei überschaubaren Mengen einfacher und
|
||||||
// zuverlässiger als einzelne Aktualisierungen.
|
// zuverlässiger als einzelne Aktualisierungen.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!map || !ready) return
|
if (!map || !ready) return
|
||||||
|
const instance = map
|
||||||
|
|
||||||
for (const m of markerObjects) m.remove()
|
for (const m of markerObjects) m.remove()
|
||||||
markerObjects = []
|
markerObjects = []
|
||||||
|
clearIcons()
|
||||||
|
|
||||||
for (const data of markers) {
|
for (const data of markers) {
|
||||||
const el = document.createElement('button')
|
const el = document.createElement('button')
|
||||||
|
|
@ -198,15 +365,28 @@
|
||||||
el.title = data.label
|
el.title = data.label
|
||||||
el.setAttribute('aria-label', data.label)
|
el.setAttribute('aria-label', data.label)
|
||||||
el.style.cssText = `
|
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;
|
border: 2px solid white; cursor: pointer; padding: 0;
|
||||||
|
display: grid; place-items: center; color: white;
|
||||||
background: ${data.color};
|
background: ${data.color};
|
||||||
opacity: ${data.resolved ? '0.35' : '1'};
|
opacity: ${data.resolved ? '0.35' : '1'};
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,.4);
|
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) => {
|
el.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation()
|
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.addEventListener('focus', () => {
|
||||||
el.style.outline = '2px solid #0ea5e9'
|
el.style.outline = '2px solid #0ea5e9'
|
||||||
|
|
@ -219,7 +399,7 @@
|
||||||
markerObjects.push(
|
markerObjects.push(
|
||||||
new maplibregl.Marker({ element: el })
|
new maplibregl.Marker({ element: el })
|
||||||
.setLngLat([data.lng, data.lat])
|
.setLngLat([data.lng, data.lat])
|
||||||
.addTo(map),
|
.addTo(instance),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -234,20 +414,11 @@
|
||||||
highlightMarker = null
|
highlightMarker = null
|
||||||
|
|
||||||
const coords = geojson?.coordinates
|
const coords = geojson?.coordinates
|
||||||
if (highlightDistance == null || !coords?.length || !coordDistances.length) return
|
if (highlightDistance == null || !coords?.length) return
|
||||||
|
|
||||||
// Nächstgelegene Koordinate zur gesuchten Distanz
|
const at = positionAtDistance(coords, coordDistances, highlightDistance)
|
||||||
let best = 0
|
if (!at) return
|
||||||
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 idx = Math.min(coords.length - 1, best)
|
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.style.cssText = `
|
el.style.cssText = `
|
||||||
width: 12px; height: 12px; border-radius: 9999px;
|
width: 12px; height: 12px; border-radius: 9999px;
|
||||||
|
|
@ -255,9 +426,7 @@
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,.4);
|
box-shadow: 0 1px 4px rgba(0,0,0,.4);
|
||||||
`
|
`
|
||||||
|
|
||||||
highlightMarker = new maplibregl.Marker({ element: el })
|
highlightMarker = new maplibregl.Marker({ element: el }).setLngLat(at).addTo(map)
|
||||||
.setLngLat(coords[idx])
|
|
||||||
.addTo(map)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -265,11 +434,52 @@
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
style:height
|
style:height
|
||||||
class="w-full rounded-lg overflow-hidden border"
|
class="w-full rounded-lg overflow-hidden border"
|
||||||
class:cursor-crosshair={placing}
|
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
{#if placing}
|
<!--
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
Popups hängt maplibre selbst in den Karten-Container, deshalb greifen die
|
||||||
Klicke auf die Karte, um die Stelle zu markieren.
|
Regeln nur global. Bewusst helle Fläche wie das maplibre-Standardpopup —
|
||||||
</p>
|
im Dunkelmodus liegt es trotzdem lesbar über der hellen Karte.
|
||||||
{/if}
|
-->
|
||||||
|
<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
|
<thead
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="table-header"
|
data-slot="table-header"
|
||||||
class={cn("[&_tr]:border-b", className)}
|
class={cn("[&_tr]:border-b [&_tr]:hover:bg-transparent", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@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 { 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.
|
// Zwei Punkte in Hersbruck, rund 1,4 km auseinander.
|
||||||
const SAMPLE = `<?xml version="1.0"?>
|
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),
|
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 { api } from './pocketbase.svelte'
|
||||||
import { app } from './app.svelte'
|
import { app } from './app.svelte'
|
||||||
import { getTeamContext } from './teams.svelte'
|
import { getTeamContext } from './teams.svelte'
|
||||||
|
import { dayOf, todayISO } from '$lib/time'
|
||||||
import type { EventsResponse } from '$lib/types'
|
import type { EventsResponse } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('event')
|
const KEY = Symbol('event')
|
||||||
|
|
@ -87,14 +88,56 @@ export class EventStore {
|
||||||
return this.records.find((r) => r.id === id)
|
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() {
|
destroy() {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { getContext, setContext } from 'svelte'
|
||||||
import { api } from './pocketbase.svelte'
|
import { api } from './pocketbase.svelte'
|
||||||
import { app } from './app.svelte'
|
import { app } from './app.svelte'
|
||||||
import { getTeamContext } from './teams.svelte'
|
import { getTeamContext } from './teams.svelte'
|
||||||
import type { RidersResponse } from '$lib/types'
|
import type { RidersResponse, UsersResponse } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('rider')
|
const KEY = Symbol('rider')
|
||||||
|
|
||||||
|
|
@ -34,6 +34,15 @@ export class RiderStore {
|
||||||
return this.records.filter((r) => r.team === teamId)
|
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() {
|
async load() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
@ -102,6 +111,34 @@ export class RiderStore {
|
||||||
return this.records.find((r) => r.id === id)
|
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) {
|
getByEvent(eventId: string) {
|
||||||
return this.scoped.filter((r) => r.event === eventId)
|
return this.scoped.filter((r) => r.event === eventId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ export class TeamStore {
|
||||||
loading = $state(false)
|
loading = $state(false)
|
||||||
error = $state<string | null>(null)
|
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
|
private unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
get active(): TeamsResponse | 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) */
|
/** User per E-Mail suchen (für Einladungs-UI) */
|
||||||
async findUserByEmail(email: string): Promise<UsersResponse | null> {
|
async findUserByEmail(email: string): Promise<UsersResponse | null> {
|
||||||
try {
|
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 { api } from './pocketbase.svelte'
|
||||||
import { app } from './app.svelte'
|
import { app } from './app.svelte'
|
||||||
import { getTeamContext } from './teams.svelte'
|
import { getTeamContext } from './teams.svelte'
|
||||||
|
import { formatDuration } from '$lib/time'
|
||||||
import type { TimesResponse, TimesStatusOptions } from '$lib/types'
|
import type { TimesResponse, TimesStatusOptions } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('time')
|
const KEY = Symbol('time')
|
||||||
|
|
@ -13,18 +14,22 @@ export interface TimeWithDuration extends TimesResponse {
|
||||||
formattedTime?: string
|
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
|
if (!t.start) return t
|
||||||
const start = new Date(t.start).getTime()
|
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
|
let duration = (end - start) / 1000
|
||||||
if (t.correction) duration += t.correction
|
if (t.correction) duration += t.correction
|
||||||
const minutes = Math.floor(duration / 60)
|
|
||||||
const seconds = (duration % 60).toFixed(1)
|
|
||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
duration,
|
duration,
|
||||||
formattedTime: `${minutes}:${seconds.padStart(4, '0')}`,
|
formattedTime: formatDuration(duration),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,12 +128,29 @@ export class TimeStore {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getByRun(runId: string): TimeWithDuration[] {
|
getByRun(runId: string, now = Date.now()): TimeWithDuration[] {
|
||||||
return this.scoped.filter((r) => r.run === runId).map(enrich)
|
return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
getByRider(riderId: string): TimeWithDuration[] {
|
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
|
||||||
return this.scoped.filter((r) => r.rider === riderId).map(enrich)
|
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) {
|
isRiderActive(runId: string, riderId: string) {
|
||||||
|
|
@ -144,11 +166,23 @@ export class TimeStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
get running(): TimeWithDuration[] {
|
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[] {
|
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[] {
|
getLeaderboard(runId: string): TimeWithDuration[] {
|
||||||
|
|
@ -157,6 +191,12 @@ export class TimeStore {
|
||||||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
|
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Platz eines Fahrers im Run, 1-basiert; null ohne gewertete Zeit. */
|
||||||
|
placement(runId: string, riderId: string): number | null {
|
||||||
|
const idx = this.getLeaderboard(runId).findIndex((t) => t.rider === riderId)
|
||||||
|
return idx === -1 ? null : idx + 1
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.unsubscribe) {
|
if (this.unsubscribe) {
|
||||||
this.unsubscribe()
|
this.unsubscribe()
|
||||||
|
|
|
||||||
|
|
@ -105,14 +105,19 @@ export class TrailFlagStore {
|
||||||
* unangetastet — der Knopf lässt sich also gefahrlos mehrfach drücken.
|
* unangetastet — der Knopf lässt sich also gefahrlos mehrfach drücken.
|
||||||
* Gibt die Anzahl neu angelegter Flags zurück.
|
* Gibt die Anzahl neu angelegter Flags zurück.
|
||||||
*/
|
*/
|
||||||
async seedDefaults(): Promise<number> {
|
async seedDefaults(teamId = this.teams.activeId): Promise<number> {
|
||||||
const teamId = this.teams.activeId
|
|
||||||
if (!teamId) throw new Error('Kein aktives Team')
|
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.')
|
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
|
let created = 0
|
||||||
|
|
||||||
for (const flag of DEFAULT_FLAGS) {
|
for (const flag of DEFAULT_FLAGS) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { getContext, setContext } from 'svelte'
|
import { getContext, setContext } from 'svelte'
|
||||||
import { api, auth } from './pocketbase.svelte'
|
import { api, auth } from './pocketbase.svelte'
|
||||||
import { getTeamContext } from './teams.svelte'
|
import { getTeamContext } from './teams.svelte'
|
||||||
import type { TrailMarkersResponse } from '$lib/types'
|
import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('trailMarker')
|
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 {
|
export class TrailMarkerStore {
|
||||||
records = $state<TrailMarkersResponse[]>([])
|
records = $state<TrailMarker[]>([])
|
||||||
loading = $state(false)
|
loading = $state(false)
|
||||||
error = $state<string | null>(null)
|
error = $state<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -14,14 +22,14 @@ export class TrailMarkerStore {
|
||||||
private teams = getTeamContext()
|
private teams = getTeamContext()
|
||||||
|
|
||||||
/** Alle Marker eines Trails, jüngste zuerst */
|
/** Alle Marker eines Trails, jüngste zuerst */
|
||||||
byTrail(trailId: string): TrailMarkersResponse[] {
|
byTrail(trailId: string): TrailMarker[] {
|
||||||
return this.records
|
return this.records
|
||||||
.filter((r) => r.trail === trailId)
|
.filter((r) => r.trail === trailId)
|
||||||
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nur die offenen — was auf der Karte auffallen soll */
|
/** 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)
|
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.
|
* Team-Admins — dieselbe Regel wie in der updateRule der Collection.
|
||||||
* Der Trail wird übergeben, weil der Store ihn nicht kennt.
|
* 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
|
const uid = auth.user?.id
|
||||||
if (!uid) return false
|
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() {
|
async load() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
try {
|
try {
|
||||||
this.records = await api.collection('trail_markers').getFullList({
|
this.records = await api.collection('trail_markers').getFullList({
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
|
expand: 'created_by',
|
||||||
requestKey: null,
|
requestKey: null,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -61,16 +82,31 @@ export class TrailMarkerStore {
|
||||||
subscribe() {
|
subscribe() {
|
||||||
if (this.unsubscribe) return
|
if (this.unsubscribe) return
|
||||||
api.collection('trail_markers').subscribe('*', (e) => {
|
api.collection('trail_markers').subscribe('*', (e) => {
|
||||||
const idx = this.records.findIndex((r) => r.id === e.record.id)
|
// Realtime liefert den Datensatz ungetypt; angefordert wird unten
|
||||||
if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records]
|
// dasselbe expand wie beim Laden.
|
||||||
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
|
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)
|
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
|
this.unsubscribe = unsub
|
||||||
}).catch((e) => console.warn('trail_markers subscribe failed:', e))
|
}).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
|
const teamId = this.teams.activeId
|
||||||
if (!teamId) throw new Error('Kein aktives Team')
|
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)
|
return await api.collection('trail_markers').update(id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,18 @@ export class TrailStore {
|
||||||
return await api.collection('trails').update(id, data)
|
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[]) {
|
async setStewards(id: string, userIds: string[]) {
|
||||||
return await api.collection('trails').update(id, { stewards: userIds })
|
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
|
// Record types for each collection
|
||||||
|
|
||||||
export enum EventsStatusOptions {
|
|
||||||
"draft" = "draft",
|
|
||||||
"active" = "active",
|
|
||||||
"finished" = "finished",
|
|
||||||
}
|
|
||||||
export type EventsRecord = {
|
export type EventsRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
description?: string
|
description?: string
|
||||||
|
ends?: IsoDateString
|
||||||
gps?: string
|
gps?: string
|
||||||
id: string
|
id: string
|
||||||
location?: string
|
location?: string
|
||||||
name?: string
|
name?: string
|
||||||
status?: EventsStatusOptions
|
starts?: IsoDateString
|
||||||
team?: RecordIdString
|
team?: RecordIdString
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +70,7 @@ export type RidersRecord = {
|
||||||
number?: string
|
number?: string
|
||||||
team?: RecordIdString
|
team?: RecordIdString
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
|
user?: RecordIdString
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunsRecord = {
|
export type RunsRecord = {
|
||||||
|
|
@ -147,8 +144,10 @@ export type TrailFlagsRecord = {
|
||||||
export type TrailMarkersRecord = {
|
export type TrailMarkersRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
created_by?: RecordIdString
|
created_by?: RecordIdString
|
||||||
flag: RecordIdString
|
flag?: RecordIdString
|
||||||
|
icon?: string
|
||||||
id: string
|
id: string
|
||||||
|
label?: string
|
||||||
lat: number
|
lat: number
|
||||||
lng: number
|
lng: number
|
||||||
note?: string
|
note?: string
|
||||||
|
|
@ -188,6 +187,8 @@ export type TrailsRecord = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
status?: TrailsStatusOptions
|
status?: TrailsStatusOptions
|
||||||
|
status_by?: RecordIdString
|
||||||
|
status_changed?: IsoDateString
|
||||||
stewards?: RecordIdString[]
|
stewards?: RecordIdString[]
|
||||||
team: RecordIdString
|
team: RecordIdString
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
|
|
@ -201,6 +202,7 @@ export type UsersRecord = {
|
||||||
id: string
|
id: string
|
||||||
name?: string
|
name?: string
|
||||||
password: string
|
password: string
|
||||||
|
superadmin?: boolean
|
||||||
tokenKey: string
|
tokenKey: string
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
username: string
|
username: string
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import Logo from '$lib/components/Logo.svelte'
|
import Logo from '$lib/components/Logo.svelte'
|
||||||
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
|
import 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 { toggleMode, mode } from 'mode-watcher'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
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 = [
|
const nav = [
|
||||||
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
||||||
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
||||||
|
{ href: '/dashboard/riders', icon: Bike, label: 'Fahrer' },
|
||||||
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
|
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
|
||||||
{ href: '/dashboard/timing', icon: Timer, label: 'Zeitnahme' },
|
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
|
||||||
{ href: '/dashboard/results', icon: Flag, label: 'Ergebnisse' },
|
|
||||||
{ href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' },
|
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -96,43 +99,73 @@
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<header class="border-b bg-card">
|
<header class="border-b bg-card">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-16 items-center justify-between">
|
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
|
||||||
<Logo />
|
voller Groesse wuerde es die 64 px hohe Leiste
|
||||||
<div class="hidden md:block">
|
ueberragen und beschnitten wirken. -->
|
||||||
<TeamSwitcher />
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
</div>
|
<Logo class="h-11 w-11 shrink-0" />
|
||||||
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
|
|
||||||
{#if mode.current === 'dark'}
|
<nav class="hidden md:flex items-center gap-1">
|
||||||
<Sun class="h-4 w-4" />
|
{#each nav as item}
|
||||||
{:else}
|
{@const Icon = item.icon}
|
||||||
<Moon class="h-4 w-4" />
|
<a
|
||||||
{/if}
|
href={item.href}
|
||||||
</Button>
|
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>
|
</div>
|
||||||
|
|
||||||
<nav class="hidden md:flex items-center gap-1">
|
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
|
||||||
{#each nav as item}
|
Zeile, das Team-Dropdown darunter. -->
|
||||||
{@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 class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="hidden md:flex items-center gap-2">
|
<!-- Wer man ist, steht links; was man tun kann, rechts. -->
|
||||||
<div class="text-sm text-right">
|
<div class="hidden md:flex min-w-56 flex-col gap-1">
|
||||||
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
|
<div class="flex items-center justify-between gap-3">
|
||||||
<p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>
|
<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>
|
</div>
|
||||||
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
|
|
||||||
<LogOut class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -167,14 +200,28 @@
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
<Separator class="my-2" />
|
<Separator class="my-2" />
|
||||||
<div class="flex items-center justify-between px-3 py-2">
|
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
||||||
<div class="text-sm">
|
<div class="flex flex-col items-start gap-1 min-w-0">
|
||||||
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
|
<span class="flex items-center gap-2 min-w-0">
|
||||||
<p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>
|
<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>
|
</div>
|
||||||
<Button variant="ghost" size="icon" onclick={handleLogout}>
|
|
||||||
<LogOut class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,5 +231,8 @@
|
||||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
|
||||||
|
<RunningTimesToast />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
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 teams = getTeamContext()
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
|
|
@ -16,6 +17,10 @@
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
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({
|
const stats = $derived({
|
||||||
totalEvents: events.scoped.length,
|
totalEvents: events.scoped.length,
|
||||||
activeEvents: events.active.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
|
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -49,7 +54,7 @@
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-3xl font-bold">{stats.totalEvents}</div>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -87,44 +92,48 @@
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="hover:border-primary transition-colors">
|
<Card class="hover:border-primary transition-colors">
|
||||||
<a href="/dashboard/timing" class="block p-6">
|
<a href="/dashboard/trails" class="block p-6">
|
||||||
<Timer class="h-6 w-6 text-muted-foreground mb-3" />
|
<Route class="h-6 w-6 text-muted-foreground mb-3" />
|
||||||
<h3 class="font-semibold">Zeitnahme</h3>
|
<h3 class="font-semibold">Trails</h3>
|
||||||
<p class="text-sm text-muted-foreground mt-1">Zeiten starten und stoppen</p>
|
<p class="text-sm text-muted-foreground mt-1">Strecken, Zustand und Meldungen</p>
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="hover:border-primary transition-colors">
|
<Card class="hover:border-primary transition-colors">
|
||||||
<a href="/dashboard/results" class="block p-6">
|
<a href="/dashboard/settings/team" class="block p-6">
|
||||||
<Flag class="h-6 w-6 text-muted-foreground mb-3" />
|
<UsersRound class="h-6 w-6 text-muted-foreground mb-3" />
|
||||||
<h3 class="font-semibold">Ergebnisse</h3>
|
<h3 class="font-semibold">Teams</h3>
|
||||||
<p class="text-sm text-muted-foreground mt-1">Zeiten und Rankings</p>
|
<p class="text-sm text-muted-foreground mt-1">Mitglieder und Rechte</p>
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold mb-4">Aktive Events</h2>
|
<h2 class="text-xl font-semibold mb-4">Heute und demnächst</h2>
|
||||||
{#if events.active.length === 0}
|
{#if upNext.length === 0}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-12 text-center">
|
<CardContent class="py-12 text-center">
|
||||||
<Calendar class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
<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">
|
<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>
|
</p>
|
||||||
<Button href="/dashboard/events" variant="outline" class="mt-4">Events ansehen</Button>
|
<Button href="/dashboard/events" variant="outline" class="mt-4">Events ansehen</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<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}
|
{@const eventRuns = runs.getByEvent(event.id).length}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<CardTitle>{event.name || event.description || 'Unbenannt'}</CardTitle>
|
<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>
|
</div>
|
||||||
{#if event.location}
|
{#if event.location}
|
||||||
<CardDescription class="flex items-center gap-1">
|
<CardDescription class="flex items-center gap-1">
|
||||||
|
|
@ -135,7 +144,7 @@
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="flex items-center justify-between">
|
<CardContent class="flex items-center justify-between">
|
||||||
<p class="text-sm text-muted-foreground">{eventRuns} Run{eventRuns === 1 ? '' : 's'}</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -7,25 +7,24 @@
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import * as Dialog from '@/components/ui/dialog'
|
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 { 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 events = getEventContext()
|
||||||
const runs = getRunContext()
|
const runs = getRunContext()
|
||||||
|
|
||||||
let showDialog = $state(false)
|
let showDialog = $state(false)
|
||||||
let editing = $state<EventsResponse | null>(null)
|
let editing = $state<EventsResponse | null>(null)
|
||||||
let form = $state<{
|
let form = $state({ name: '', description: '', location: '', starts: '', ends: '' })
|
||||||
name: string
|
let multiDay = $state(false)
|
||||||
description: string
|
let formError = $state<string | null>(null)
|
||||||
location: string
|
|
||||||
status: EventsStatusOptions
|
|
||||||
}>({ name: '', description: '', location: '', status: 'draft' as EventsStatusOptions })
|
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editing = null
|
editing = null
|
||||||
form = { name: '', description: '', location: '', status: 'draft' as EventsStatusOptions }
|
form = { name: '', description: '', location: '', starts: '', ends: '' }
|
||||||
|
multiDay = false
|
||||||
|
formError = null
|
||||||
showDialog = true
|
showDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,29 +34,47 @@
|
||||||
name: e.name ?? '',
|
name: e.name ?? '',
|
||||||
description: e.description ?? '',
|
description: e.description ?? '',
|
||||||
location: e.location ?? '',
|
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
|
showDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.name.trim()) return
|
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 {
|
try {
|
||||||
if (editing) await events.edit(editing.id, form)
|
// Ein eintägiges Event hat kein Ende — sonst stünde überall
|
||||||
else await events.create(form)
|
// 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
|
showDialog = false
|
||||||
} catch (e: any) {
|
} 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([
|
const groups = $derived([
|
||||||
{ title: 'Aktive Events', items: events.active, status: 'active' as const, showZero: false },
|
{ title: 'Läuft gerade', items: events.active },
|
||||||
{ title: 'Entwürfe', items: events.drafts, status: 'draft' as const, showZero: false },
|
{ title: 'Anstehend', items: events.upcoming },
|
||||||
{ title: 'Abgeschlossen', items: events.finished, status: 'finished' as const, showZero: false },
|
{ title: 'Ohne Termin', items: events.undated },
|
||||||
|
{ title: 'Vorbei', items: events.past },
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -102,9 +119,11 @@
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<CardTitle class="leading-tight">{event.name || 'Unbenannt'}</CardTitle>
|
<CardTitle class="leading-tight">{event.name || 'Unbenannt'}</CardTitle>
|
||||||
<Badge variant={statusVariants[event.status as keyof typeof statusVariants] ?? 'secondary'}>
|
{#if event.starts}
|
||||||
{statusLabels[event.status as keyof typeof statusLabels] ?? 'Entwurf'}
|
<Badge variant="secondary" class="shrink-0">
|
||||||
</Badge>
|
{formatDateRange(event.starts, event.ends)}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if event.location}
|
{#if event.location}
|
||||||
<CardDescription class="flex items-center gap-1">
|
<CardDescription class="flex items-center gap-1">
|
||||||
|
|
@ -168,19 +187,30 @@
|
||||||
<Input id="event-desc" bind:value={form.description} placeholder="Optionale Beschreibung" />
|
<Input id="event-desc" bind:value={form.description} placeholder="Optionale Beschreibung" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Status</Label>
|
<Label for="event-starts">Datum</Label>
|
||||||
<Select.Root type="single" bind:value={form.status}>
|
<Input id="event-starts" type="date" bind:value={form.starts} />
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
<Dialog.Footer>
|
||||||
<Button type="button" variant="ghost" onclick={() => (showDialog = false)}>Abbrechen</Button>
|
<Button type="button" variant="ghost" onclick={() => (showDialog = false)}>Abbrechen</Button>
|
||||||
<Button type="submit">{editing ? 'Speichern' : 'Erstellen'}</Button>
|
<Button type="submit">{editing ? 'Speichern' : 'Erstellen'}</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getRunContext } from '$lib/stores/runs.svelte'
|
import { getRunContext } from '$lib/stores/runs.svelte'
|
||||||
import { fullName, getRiderContext } from '$lib/stores/riders.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 { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -20,18 +24,31 @@
|
||||||
Timer,
|
Timer,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
Flag,
|
|
||||||
} from 'lucide-svelte'
|
} from 'lucide-svelte'
|
||||||
import type { RidersResponse, RunsResponse } from '$lib/types'
|
import type { RidersResponse, RunsResponse } from '$lib/types'
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const runs = getRunContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
|
const times = getTimeContext()
|
||||||
|
|
||||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||||
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
|
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
|
||||||
const eventRuns = $derived(event ? runs.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
|
// Fahrer-Dialog
|
||||||
let riderDialog = $state(false)
|
let riderDialog = $state(false)
|
||||||
let ridingEdit = $state<RidersResponse | null>(null)
|
let ridingEdit = $state<RidersResponse | null>(null)
|
||||||
|
|
@ -125,9 +142,12 @@
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h1 class="text-3xl font-bold tracking-tight">{event.name || 'Unbenannt'}</h1>
|
<h1 class="text-3xl font-bold tracking-tight">{event.name || 'Unbenannt'}</h1>
|
||||||
<Badge variant={event.status === 'active' ? 'default' : event.status === 'finished' ? 'outline' : 'secondary'}>
|
{#if event.starts}
|
||||||
{event.status === 'active' ? 'Aktiv' : event.status === 'finished' ? 'Abgeschlossen' : 'Entwurf'}
|
<Badge variant="secondary" class="gap-1">
|
||||||
</Badge>
|
<Calendar class="h-3 w-3" />
|
||||||
|
{formatDateRange(event.starts, event.ends)}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if event.location}
|
{#if event.location}
|
||||||
<p class="mt-1 text-muted-foreground flex items-center gap-1">
|
<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>
|
<p class="mt-2 text-sm text-muted-foreground">{event.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if event.status === 'active'}
|
<Button
|
||||||
<Button href="/dashboard/timing?event={event.id}">
|
onclick={() => (timeDialog = true)}
|
||||||
<Timer class="h-4 w-4 mr-2" />
|
disabled={eventRiders.length === 0 || eventRuns.length === 0}
|
||||||
Zeitnahme
|
>
|
||||||
</Button>
|
<Timer class="h-4 w-4 mr-2" />
|
||||||
{/if}
|
Zeit erfassen
|
||||||
<Button href="/dashboard/results?event={event.id}" variant="outline">
|
</Button>
|
||||||
<Flag class="h-4 w-4 mr-2" />
|
|
||||||
Ergebnisse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
@ -164,12 +180,14 @@
|
||||||
Fahrer
|
Fahrer
|
||||||
<span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
|
<span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
|
||||||
</h2>
|
</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>
|
</div>
|
||||||
<Button onclick={openRiderCreate}>
|
{#if riders.canManage}
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Button onclick={openRiderCreate}>
|
||||||
Fahrer hinzufügen
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Fahrer hinzufügen
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -178,11 +196,17 @@
|
||||||
<div class="py-12 text-center">
|
<div class="py-12 text-center">
|
||||||
<Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
<Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||||
<p class="font-medium">Noch keine Fahrer</p>
|
<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>
|
{#if riders.canManage}
|
||||||
<Button onclick={openRiderCreate}>
|
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Button onclick={openRiderCreate}>
|
||||||
Ersten Fahrer hinzufügen
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Ersten Fahrer hinzufügen
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
|
Fahrer legt die Teamleitung an.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
|
|
@ -190,29 +214,39 @@
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head class="w-20">#</Table.Head>
|
<Table.Head class="w-20">#</Table.Head>
|
||||||
<Table.Head>Name</Table.Head>
|
<Table.Head>Name</Table.Head>
|
||||||
|
<Table.Head class="w-40">Zeiten</Table.Head>
|
||||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each eventRiders as rider (rider.id)}
|
{#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>
|
<Table.Cell>
|
||||||
<Badge variant="outline" class="font-mono">{rider.number || '—'}</Badge>
|
<Badge variant="outline">{rider.number || '—'}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
{#if fullName(rider)}
|
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||||
{fullName(rider)}
|
<a href={riderPath(rider.id)} class="hover:underline">
|
||||||
{:else}
|
{#if fullName(rider)}
|
||||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
{fullName(rider)}
|
||||||
{/if}
|
{:else}
|
||||||
|
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right">
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
|
{done} von {eventRuns.length} Runs
|
||||||
<Pencil class="h-4 w-4" />
|
</Table.Cell>
|
||||||
</Button>
|
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
||||||
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
|
{#if riders.canManage}
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
|
||||||
</Button>
|
<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.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -234,7 +268,7 @@
|
||||||
Runs
|
Runs
|
||||||
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
|
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
|
||||||
</h2>
|
</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>
|
</div>
|
||||||
<Button onclick={openRunCreate}>
|
<Button onclick={openRunCreate}>
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
|
|
@ -259,14 +293,26 @@
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Bezeichnung</Table.Head>
|
<Table.Head>Bezeichnung</Table.Head>
|
||||||
|
<Table.Head class="w-48">Zeiten</Table.Head>
|
||||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each eventRuns as run (run.id)}
|
{#each eventRuns as run (run.id)}
|
||||||
<Table.Row>
|
{@const runTimes = times.getByRun(run.id)}
|
||||||
<Table.Cell class="font-medium">{run.description}</Table.Cell>
|
{@const active = runTimes.filter((t) => t.status === 'active').length}
|
||||||
<Table.Cell class="text-right">
|
{@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)}>
|
<Button variant="ghost" size="icon" onclick={() => openRunEdit(run)}>
|
||||||
<Pencil class="h-4 w-4" />
|
<Pencil class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -350,3 +396,5 @@
|
||||||
</form>
|
</form>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</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 { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import * as Dialog from '@/components/ui/dialog'
|
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 { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte'
|
||||||
import type { TrailFlagsResponse } from '$lib/types'
|
import type { TrailFlagsResponse } from '$lib/types'
|
||||||
|
|
||||||
|
|
@ -104,10 +106,9 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold">Flag-Typen</h1>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
Wofür sich Marker auf einem Trail setzen lassen.
|
Wofür sich Marker auf einem Trail setzen lassen.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -149,7 +150,7 @@
|
||||||
{#each flags.scoped as flag (flag.id)}
|
{#each flags.scoped as flag (flag.id)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-3 flex items-center gap-3">
|
<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>
|
<span class="font-medium flex-1">{flag.label}</span>
|
||||||
<Badge variant={flag.severity === 'kritisch' ? 'destructive' : 'secondary'}>
|
<Badge variant={flag.severity === 'kritisch' ? 'destructive' : 'secondary'}>
|
||||||
{flag.severity}
|
{flag.severity}
|
||||||
|
|
@ -201,13 +202,8 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<IconPicker id="flag-icon" bind:value={form.icon} color={form.color} />
|
||||||
<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>
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-sm text-destructive">{error}</p>
|
<p class="text-sm text-destructive">{error}</p>
|
||||||
{/if}
|
{/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 { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
||||||
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
||||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||||
|
import { trailStatus } from '$lib/trailStatus'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -22,18 +23,6 @@
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
let error = $state<string | null>(null)
|
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][] }
|
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
|
||||||
|
|
||||||
/** Aktive Version eines Trails, für Kennzahlen und Kartenlinie */
|
/** Aktive Version eines Trails, für Kennzahlen und Kartenlinie */
|
||||||
|
|
@ -119,14 +108,19 @@
|
||||||
{#each trails.scoped as trail (trail.id)}
|
{#each trails.scoped as trail (trail.id)}
|
||||||
{@const version = currentVersion(trail.id)}
|
{@const version = currentVersion(trail.id)}
|
||||||
{@const open = markers.openByTrail(trail.id).length}
|
{@const open = markers.openByTrail(trail.id).length}
|
||||||
|
{@const status = trailStatus(trail.status)}
|
||||||
<a href="/dashboard/trails/{trail.id}" class="block">
|
<a href="/dashboard/trails/{trail.id}" class="block">
|
||||||
<Card class="h-full hover:border-primary transition-colors">
|
<Card class="h-full hover:border-primary transition-colors">
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<CardTitle class="text-base">{trail.name}</CardTitle>
|
<CardTitle class="text-base">{trail.name}</CardTitle>
|
||||||
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
|
<span
|
||||||
{STATUS_LABEL[trail.status] ?? trail.status}
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
|
||||||
</Badge>
|
style="color: {status.color}; border-color: {status.color}"
|
||||||
|
>
|
||||||
|
<span class="size-1.5 rounded-full" style:background={status.color}></span>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-3">
|
<CardContent class="space-y-3">
|
||||||
|
|
@ -135,6 +129,7 @@
|
||||||
geojson={version.geojson}
|
geojson={version.geojson}
|
||||||
bounds={version.bounds}
|
bounds={version.bounds}
|
||||||
status={trail.status}
|
status={trail.status}
|
||||||
|
controls={false}
|
||||||
height="140px"
|
height="140px"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,28 @@
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import TrailMap from '$lib/components/TrailMap.svelte'
|
import TrailMap from '$lib/components/TrailMap.svelte'
|
||||||
import ElevationProfile from '$lib/components/ElevationProfile.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 { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import * as Dialog from '@/components/ui/dialog'
|
import * as Dialog from '@/components/ui/dialog'
|
||||||
|
import * as DropdownMenu from '@/components/ui/dropdown-menu'
|
||||||
import * as Select from '@/components/ui/select'
|
import * as Select from '@/components/ui/select'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Upload, MapPin, MessageSquare, History,
|
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
|
||||||
Check, Trash2, Download, Plus, AlertTriangle,
|
Check, Trash2, Download, Plus, Ruler, TrendingUp,
|
||||||
} from 'lucide-svelte'
|
} from 'lucide-svelte'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { browser } from '$app/environment'
|
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
|
// Generics der JSON-Felder, wie in trails/+page.svelte — die
|
||||||
// Rohdaten kommen ungetypt aus PocketBase zurück.
|
// Rohdaten kommen ungetypt aus PocketBase zurück.
|
||||||
|
|
@ -65,23 +72,54 @@
|
||||||
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
|
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
|
||||||
const trailMarkers = $derived(trail ? markers.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(
|
const mapMarkers = $derived(
|
||||||
trailMarkers.map((m) => {
|
trailMarkers.map((m) => {
|
||||||
const flag = flags.getById(m.flag)
|
const look = markerLook(m)
|
||||||
return {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
lat: m.lat,
|
lat: m.lat,
|
||||||
lng: m.lng,
|
lng: m.lng,
|
||||||
color: flag?.color ?? '#64748b',
|
color: look.color,
|
||||||
resolved: !!m.resolved,
|
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)
|
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 ------------------------------------------------------
|
// --- GPX-Upload ------------------------------------------------------
|
||||||
let uploadDialog = $state(false)
|
let uploadDialog = $state(false)
|
||||||
|
|
@ -112,26 +150,88 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Marker setzen ---------------------------------------------------
|
// --- 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 markerDialog = $state(false)
|
||||||
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
|
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 markerNote = $state('')
|
||||||
let markerError = $state<string | null>(null)
|
let markerError = $state<string | null>(null)
|
||||||
|
|
||||||
function onPlace(coords: { lat: number; lng: number }) {
|
// Ohne Flag-Typen geht es trotzdem: Dann ist jede Meldung eine eigene.
|
||||||
markerCoords = coords
|
const canPlace = $derived(!!trail)
|
||||||
markerFlag = flags.scoped[0]?.id ?? ''
|
|
||||||
|
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 = ''
|
markerNote = ''
|
||||||
markerError = null
|
markerError = null
|
||||||
placing = false
|
|
||||||
markerDialog = true
|
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() {
|
async function saveMarker() {
|
||||||
if (!trail || !markerCoords) return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,12 +239,17 @@
|
||||||
await markers.create({
|
await markers.create({
|
||||||
trail: trail.id,
|
trail: trail.id,
|
||||||
flag: markerFlag,
|
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,
|
lat: markerCoords.lat,
|
||||||
lng: markerCoords.lng,
|
lng: markerCoords.lng,
|
||||||
note: markerNote,
|
note: markerNote,
|
||||||
})
|
})
|
||||||
markerDialog = false
|
markerDialog = false
|
||||||
markerCoords = null
|
markerCoords = null
|
||||||
|
markerDistance = null
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.'
|
markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.'
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +257,9 @@
|
||||||
|
|
||||||
// --- Kommentare ------------------------------------------------------
|
// --- Kommentare ------------------------------------------------------
|
||||||
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
|
// 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 commentText = $state('')
|
||||||
let commentError = $state<string | null>(null)
|
let commentError = $state<string | null>(null)
|
||||||
let unsubComments: (() => void) | null = null
|
let unsubComments: (() => void) | null = null
|
||||||
|
|
@ -164,20 +271,30 @@
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
api.collection('trail_comments')
|
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) => {
|
.then((r) => {
|
||||||
if (!cancelled) comments = r
|
if (!cancelled) comments = r as Comment[]
|
||||||
})
|
})
|
||||||
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
|
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
|
||||||
|
|
||||||
api.collection('trail_comments')
|
api.collection('trail_comments')
|
||||||
.subscribe('*', (e) => {
|
.subscribe(
|
||||||
if (e.record.trail !== id) return
|
'*',
|
||||||
const idx = comments.findIndex((c) => c.id === e.record.id)
|
(e) => {
|
||||||
if (e.action === 'create' && idx === -1) comments = [e.record, ...comments]
|
if (e.record.trail !== id) return
|
||||||
else if (e.action === 'update' && idx !== -1) comments[idx] = e.record
|
const record = e.record as Comment
|
||||||
else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== e.record.id)
|
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) => {
|
.then((u) => {
|
||||||
if (cancelled) u()
|
if (cancelled) u()
|
||||||
else unsubComments = u
|
else unsubComments = u
|
||||||
|
|
@ -209,25 +326,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Hilfsfunktionen -------------------------------------------------
|
// --- 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) {
|
function formatDate(s: string) {
|
||||||
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setStatus(status: string) {
|
async function setStatus(status: string) {
|
||||||
if (!trail) return
|
if (!trail || status === trail.status) return
|
||||||
await trails.edit(trail.id, { status } as any)
|
|
||||||
|
try {
|
||||||
|
await trails.setStatus(trail.id, status)
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.message ?? 'Der Zustand konnte nicht gemeldet werden.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -242,251 +352,306 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Kopf -->
|
<!-- Kopf -->
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="space-y-2">
|
||||||
<div class="space-y-1">
|
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
|
||||||
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
|
<ArrowLeft class="size-4 mr-2" />
|
||||||
<ArrowLeft class="size-4 mr-2" />
|
Trails
|
||||||
Trails
|
</Button>
|
||||||
</Button>
|
|
||||||
<h1 class="text-2xl font-semibold">{trail.name}</h1>
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
{#if safeDescription}
|
<h1 class="text-4xl font-bold tracking-tight">{trail.name}</h1>
|
||||||
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
|
|
||||||
{STATUS_LABEL[trail.status] ?? trail.status}
|
|
||||||
</Badge>
|
|
||||||
{#if canEdit}
|
{#if canEdit}
|
||||||
<Button variant="outline" size="sm" onclick={() => (uploadDialog = true)}>
|
<DropdownMenu.Root>
|
||||||
<Upload class="size-4 mr-2" />
|
<DropdownMenu.Trigger>
|
||||||
GPX hochladen
|
{#snippet child({ props })}
|
||||||
</Button>
|
<!-- Farbe kommt aus derselben Quelle wie die Linie auf der Karte. -->
|
||||||
{/if}
|
<Button
|
||||||
</div>
|
{...props}
|
||||||
</div>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
{#if canEdit}
|
class="gap-2"
|
||||||
<div class="flex gap-2">
|
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
|
||||||
{#each ['offen', 'eingeschraenkt', 'gesperrt'] as s (s)}
|
>
|
||||||
<Button
|
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
|
||||||
variant={trail.status === s ? 'default' : 'outline'}
|
{statusInfo.label}
|
||||||
size="sm"
|
<ChevronDown class="size-3 opacity-60" />
|
||||||
onclick={() => setStatus(s)}
|
</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]}
|
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
|
||||||
</Button>
|
{statusInfo.label}
|
||||||
{/each}
|
</span>
|
||||||
</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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Reiter -->
|
{#if version}
|
||||||
<div class="flex gap-2 border-b">
|
<span class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<button
|
<span class="flex items-center gap-1.5">
|
||||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
<Ruler class="size-4" />
|
||||||
class:border-primary={tab === 'marker'}
|
<strong class="text-foreground">{(version.distance_m / 1000).toFixed(1)}</strong> km
|
||||||
class:border-transparent={tab !== 'marker'}
|
</span>
|
||||||
onclick={() => (tab = 'marker')}
|
<span class="flex items-center gap-1.5">
|
||||||
>
|
<TrendingUp class="size-4" />
|
||||||
<MapPin class="size-4 inline mr-1" />
|
<strong class="text-foreground">{version.ascent_m}</strong> hm
|
||||||
Marker ({trailMarkers.length})
|
</span>
|
||||||
</button>
|
</span>
|
||||||
<button
|
{/if}
|
||||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
</div>
|
||||||
class:border-primary={tab === 'kommentare'}
|
|
||||||
class:border-transparent={tab !== 'kommentare'}
|
{#if trail.status_changed}
|
||||||
onclick={() => (tab = 'kommentare')}
|
<p class="text-xs text-muted-foreground">
|
||||||
>
|
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
|
||||||
<MessageSquare class="size-4 inline mr-1" />
|
gemeldet am {formatDate(trail.status_changed)}
|
||||||
Kommentare ({comments.length})
|
{#if trail.status_by}
|
||||||
</button>
|
von {teams.userLabel(trail.status_by)}
|
||||||
<button
|
{/if}
|
||||||
class="px-4 py-2 text-sm border-b-2 -mb-px"
|
</p>
|
||||||
class:border-primary={tab === 'versionen'}
|
{/if}
|
||||||
class:border-transparent={tab !== 'versionen'}
|
|
||||||
onclick={() => (tab = 'versionen')}
|
{#if safeDescription}
|
||||||
>
|
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
|
||||||
<History class="size-4 inline mr-1" />
|
{/if}
|
||||||
Versionen ({trailVersions.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if tab === 'marker'}
|
<div class="grid gap-6 lg:grid-cols-3 items-start">
|
||||||
{#if trailMarkers.length === 0}
|
<!-- Karte und Höhenprofil -->
|
||||||
<p class="text-muted-foreground text-sm">
|
<Card class="lg:col-span-2">
|
||||||
Noch keine Marker. Setze einen über „Marker setzen" auf der Karte.
|
<CardContent class="pt-6 space-y-4">
|
||||||
</p>
|
{#if version?.geojson}
|
||||||
{:else}
|
<TrailMap
|
||||||
<div class="space-y-2">
|
geojson={version.geojson}
|
||||||
{#each trailMarkers as m (m.id)}
|
bounds={version.bounds}
|
||||||
{@const flag = flags.getById(m.flag)}
|
markers={mapMarkers}
|
||||||
<Card class={m.resolved ? 'opacity-60' : ''}>
|
status={trail.status}
|
||||||
<CardContent class="py-3 flex items-center gap-3">
|
coordDistances={version.coord_distances ?? []}
|
||||||
<span
|
highlightDistance={highlight}
|
||||||
class="size-3 rounded-full shrink-0"
|
onplace={canPlace ? onPlace : undefined}
|
||||||
style:background={flag?.color ?? '#64748b'}
|
ontrailhover={(d) => (highlight = d)}
|
||||||
></span>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
|
{#if version.elevation?.length}
|
||||||
{#if m.note}
|
<ElevationProfile
|
||||||
<p class="text-sm text-muted-foreground">{m.note}</p>
|
elevation={version.elevation}
|
||||||
{/if}
|
highlightDistance={highlight}
|
||||||
<p class="text-xs text-muted-foreground">{formatDate(m.created)}</p>
|
onhover={(d) => (highlight = d)}
|
||||||
</div>
|
onselect={canPlace ? placeFromProfile : undefined}
|
||||||
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
|
/>
|
||||||
<Button
|
{/if}
|
||||||
variant="ghost"
|
{:else}
|
||||||
size="sm"
|
<div class="py-12 text-center space-y-3">
|
||||||
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
<Upload class="size-10 mx-auto text-muted-foreground" />
|
||||||
onclick={async () => {
|
<p class="text-muted-foreground">Für diesen Trail gibt es noch keine GPX-Datei.</p>
|
||||||
try {
|
{#if canEdit}
|
||||||
await markers.toggleResolved(m.id)
|
<Button onclick={() => (uploadDialog = true)}>
|
||||||
} catch (e: any) {
|
<Upload class="size-4 mr-2" />
|
||||||
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
|
GPX hochladen
|
||||||
}
|
</Button>
|
||||||
}}
|
{/if}
|
||||||
>
|
</div>
|
||||||
<Check class="size-4" />
|
{/if}
|
||||||
</Button>
|
</CardContent>
|
||||||
{/if}
|
</Card>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<!-- Marker, Kommentare, Versionen -->
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if tab === 'kommentare'}
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex gap-2">
|
<Card>
|
||||||
<Input
|
<CardHeader class="pb-3">
|
||||||
bind:value={commentText}
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
placeholder="Kommentar schreiben …"
|
<MapPin class="size-4 text-primary" />
|
||||||
onkeydown={(e) => e.key === 'Enter' && addComment()}
|
Marker ({trailMarkers.length})
|
||||||
/>
|
</CardTitle>
|
||||||
<Button onclick={addComment} disabled={!commentText.trim()}>
|
</CardHeader>
|
||||||
<Plus class="size-4" />
|
<CardContent class="space-y-3">
|
||||||
</Button>
|
<p class="text-xs text-muted-foreground">
|
||||||
</div>
|
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
|
||||||
{#if commentError}
|
Marker zu setzen.
|
||||||
<p class="text-sm text-destructive">{commentError}</p>
|
</p>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if comments.length === 0}
|
{#if trailMarkers.length === 0}
|
||||||
<p class="text-muted-foreground text-sm">Noch keine Kommentare.</p>
|
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<ul class="divide-y -mx-2">
|
||||||
{#each comments as c (c.id)}
|
{#each trailMarkers as m (m.id)}
|
||||||
<Card>
|
{@const look = markerLook(m)}
|
||||||
<CardContent class="py-3">
|
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
|
||||||
<p class="text-sm">{c.text}</p>
|
<FlagIcon name={look.icon} color={look.color} class="size-5" />
|
||||||
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
|
<div class="min-w-0 flex-1">
|
||||||
</CardContent>
|
<p class="text-sm font-medium">{look.label}</p>
|
||||||
</Card>
|
{#if m.note}
|
||||||
{/each}
|
<p class="text-sm text-muted-foreground">{m.note}</p>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
<p class="text-xs text-muted-foreground">
|
||||||
</div>
|
{formatDate(m.created)} · {markers.authorName(m)}
|
||||||
{:else}
|
</p>
|
||||||
{#if trailVersions.length === 0}
|
</div>
|
||||||
<p class="text-muted-foreground text-sm">Noch keine Version hochgeladen.</p>
|
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
|
||||||
{:else}
|
<div class="flex shrink-0">
|
||||||
<div class="space-y-2">
|
<Button
|
||||||
{#each trailVersions as v (v.id)}
|
variant="ghost"
|
||||||
<Card class={v.id === trail.current ? 'border-primary' : ''}>
|
size="icon"
|
||||||
<CardContent class="py-3 flex items-center gap-3">
|
class="size-8"
|
||||||
<div class="min-w-0 flex-1">
|
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
|
||||||
<div class="flex items-center gap-2">
|
onclick={async () => {
|
||||||
<p class="text-sm font-medium">{formatDate(v.created)}</p>
|
try {
|
||||||
{#if v.id === trail.current}
|
await markers.toggleResolved(m.id)
|
||||||
<Badge>Aktiv</Badge>
|
} 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}
|
{/if}
|
||||||
</div>
|
</li>
|
||||||
<p class="text-sm text-muted-foreground">
|
{/each}
|
||||||
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
|
</ul>
|
||||||
{#if v.note}· {v.note}{/if}
|
{/if}
|
||||||
</p>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{#if v.gpx}
|
<Card>
|
||||||
<Button variant="ghost" size="sm" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
|
<CardHeader class="pb-3">
|
||||||
<Download class="size-4" />
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
</Button>
|
<MessageSquare class="size-4 text-primary" />
|
||||||
{/if}
|
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}
|
{#if comments.length === 0}
|
||||||
<Button
|
<p class="text-sm text-muted-foreground">Noch keine Kommentare.</p>
|
||||||
variant="outline"
|
{:else}
|
||||||
size="sm"
|
<ul class="divide-y -mx-2">
|
||||||
onclick={() => versions.activate(trail.id, v.id)}
|
{#each comments as c (c.id)}
|
||||||
>
|
{@const author = c.expand?.created_by}
|
||||||
Aktivieren
|
<li class="flex gap-3 px-2 py-3">
|
||||||
</Button>
|
<!-- Nur das Bild; wer dahintersteckt, sagt der Tooltip. -->
|
||||||
{/if}
|
<span
|
||||||
</CardContent>
|
use:tooltip={{
|
||||||
</Card>
|
content: author?.name || author?.email || 'Unbekannt',
|
||||||
{/each}
|
}}
|
||||||
</div>
|
>
|
||||||
{/if}
|
<Avatar user={author} size={28} />
|
||||||
{/if}
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -534,24 +699,47 @@
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Marker setzen</Dialog.Title>
|
<Dialog.Title>Marker setzen</Dialog.Title>
|
||||||
|
{#if markerDistance != null}
|
||||||
|
<Dialog.Description>
|
||||||
|
bei km {(markerDistance / 1000).toFixed(2)} des Trails
|
||||||
|
</Dialog.Description>
|
||||||
|
{/if}
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Typ</Label>
|
<Label>Typ</Label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<Select.Root type="single" value={markerFlag} onValueChange={selectFlag}>
|
||||||
{#each flags.scoped as f (f.id)}
|
<Select.Trigger class="w-full">
|
||||||
<Button
|
{#if selectedFlag}
|
||||||
variant={markerFlag === f.id ? 'default' : 'outline'}
|
<span class="flex items-center gap-2">
|
||||||
size="sm"
|
<span class="size-2 rounded-full" style:background={selectedFlag.color}></span>
|
||||||
onclick={() => (markerFlag = f.id)}
|
{selectedFlag.label}
|
||||||
>
|
</span>
|
||||||
<span class="size-2 rounded-full mr-2" style:background={f.color}></span>
|
{:else}
|
||||||
{f.label}
|
Eigener Typ
|
||||||
</Button>
|
{/if}
|
||||||
{/each}
|
</Select.Trigger>
|
||||||
</div>
|
<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>
|
</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">
|
<div class="space-y-2">
|
||||||
<Label for="marker-note">Notiz</Label>
|
<Label for="marker-note">Notiz</Label>
|
||||||
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />
|
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue