Compare commits

..

No commits in common. "27d34ad0d3bfa574b467ddbfa7945ccc677ad922" and "0432540d5a46f02c1e33be018b2de8b74c80a288" have entirely different histories.

47 changed files with 1402 additions and 4460 deletions

100
TODO.md
View file

@ -1,100 +0,0 @@
# 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.

View file

@ -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": "@request.auth.id != \"\"", "viewRule": "id = @request.auth.id",
"createRule": "@request.auth.id != \"\"", "createRule": null,
"updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false", "updateRule": "id = @request.auth.id",
"deleteRule": "id = @request.auth.id", "deleteRule": "id = @request.auth.id",
"name": "users", "name": "users",
"type": "auth", "type": "auth",
@ -125,15 +125,6 @@ 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,
@ -466,26 +457,20 @@ migrate((app) => {
"type": "text" "type": "text"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "date_event_starts", "id": "select2063623452",
"max": "", "maxSelect": 1,
"min": "", "name": "status",
"name": "starts",
"presentable": false, "presentable": false,
"required": false, "required": false,
"system": false, "system": false,
"type": "date" "type": "select",
}, "values": [
{ "draft",
"hidden": false, "active",
"id": "date_event_ends", "finished"
"max": "", ]
"min": "",
"name": "ends",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}, },
{ {
"cascadeDelete": false, "cascadeDelete": false,
@ -605,9 +590,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.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", "createRule": "team.users.id ?= @request.auth.id",
"updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", "updateRule": "team.users.id ?= @request.auth.id",
"deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", "deleteRule": "team.users.id ?= @request.auth.id",
"name": "riders", "name": "riders",
"type": "base", "type": "base",
"fields": [ "fields": [
@ -670,20 +655,6 @@ 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",
@ -1042,31 +1013,6 @@ 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_",
@ -1521,40 +1467,10 @@ migrate((app) => {
"minSelect": 0, "minSelect": 0,
"name": "flag", "name": "flag",
"presentable": false, "presentable": false,
"required": false, "required": true,
"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,

View file

@ -1,73 +0,0 @@
/// <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)
})

View file

@ -1,52 +0,0 @@
/// <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)
})

View file

@ -1,45 +0,0 @@
/// <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)
})

View file

@ -1,57 +0,0 @@
/// <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)
})

View file

@ -1,47 +0,0 @@
/// <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)
})

View file

@ -1,78 +0,0 @@
<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}

View file

@ -1,28 +1,17 @@
<script lang="ts"> <script lang="ts">
/** /**
* Höhenprofil als SVG-Fläche. Die hervorgehobene Stelle kommt als Prop * Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente die
* herein und geht beim Überfahren als Callback hinaus — die Elternseite * Distanz des Punktes; die Elternkomponente reicht sie an die Karte
* hält den Wert und reicht ihn ebenso an die Karte weiter. So zeigen * weiter, damit dort dieselbe Stelle hervorgehoben wird. Distanz statt
* Karte und Profil immer dieselbe Stelle, egal wo gerade gezeigt wird. * Index, weil die Karte eine andere (vereinfachte) Punktreihe zeigt.
* 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()
@ -57,148 +46,42 @@
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
let lo = 0 function handleMove(e: MouseEvent) {
let hi = elevation.length - 1 if (!elevation.length) return
while (hi - lo > 1) {
const mid = (lo + hi) >> 1
if (elevation[mid].d <= highlightDistance) lo = mid
else hi = mid
}
const p = const rect = (e.currentTarget as SVGElement).getBoundingClientRect()
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))
return elevation[clamped].d hoverX = ratio * W
} 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="relative w-full" style:height> <div class="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-hidden="true" aria-label="Höhenprofil"
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 hover} {#if hoverX !== null}
<line <line x1={hoverX} y1="0" x2={hoverX} y2={H} stroke="rgb(14 165 233)" stroke-width="1" vector-effect="non-scaling-stroke" />
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>

View file

@ -1,34 +0,0 @@
<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>

View file

@ -1,64 +0,0 @@
<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>

View file

@ -1,149 +0,0 @@
<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}

View file

@ -35,7 +35,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
{/each} {/each}
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => goto('/dashboard/settings/team')}> <DropdownMenu.Item onclick={() => goto('/dashboard/teams')}>
<Users class="h-4 w-4 mr-2" /> <Users class="h-4 w-4 mr-2" />
Teams verwalten Teams verwalten
</DropdownMenu.Item> </DropdownMenu.Item>

View file

@ -1,219 +0,0 @@
<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>

View file

@ -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 { mount, onMount, unmount } from 'svelte' import { onMount } from 'svelte'
import * as maplibregl from 'maplibre-gl' import * as maplibregl from 'maplibre-gl'
import type { Map as MapLibreMap, Marker, Popup } from 'maplibre-gl' import type { Map as MapLibreMap, Marker } 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,11 +22,6 @@
// 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)
@ -37,15 +32,7 @@
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 {
@ -53,89 +40,44 @@
bounds = null, bounds = null,
markers = [], markers = [],
status = 'offen', status = 'offen',
placing = false,
coordDistances = [], coordDistances = [],
highlightDistance = null, highlightDistance = null,
onplace, onplace,
ontrailhover, onmarkerclick,
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
* Wird nur beim Klick auf die Trail-Linie gerufen — und zwar mit der onmarkerclick?: (id: string) => void
* 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
* Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen // Outdoor-Karte mit Höhenlinien tauschen lässt.
* keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
* dadurch deutlich sichtbarer darüber — und es gibt sie in einer echten const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende'
* 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 = '&copy; OpenStreetMap-Mitwirkende, &copy; 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
@ -148,7 +90,7 @@
sources: { sources: {
osm: { osm: {
type: 'raster', type: 'raster',
tiles: isDark ? TILES_DARK : TILES_LIGHT, tiles: [TILES],
tileSize: 256, tileSize: 256,
attribution: ATTRIBUTION, attribution: ATTRIBUTION,
}, },
@ -157,19 +99,20 @@
}, },
center: FALLBACK_CENTER, center: FALLBACK_CENTER,
zoom: 11, zoom: 11,
attributionControl: controls ? undefined : false,
}) })
map = instance map = instance
if (controls) {
instance.addControl(new maplibregl.NavigationControl(), 'top-right') instance.addControl(new maplibregl.NavigationControl(), 'top-right')
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left') 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.
@ -177,9 +120,6 @@
markerObjects = [] markerObjects = []
highlightMarker?.remove() highlightMarker?.remove()
highlightMarker = null highlightMarker = null
popup?.remove()
popup = null
clearIcons()
instance.remove() instance.remove()
map = null map = null
@ -206,58 +146,12 @@
} }
}) })
/**
* 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 = trailStatusColor(status) const color = LINE_COLORS[status] ?? LINE_COLORS.offen
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
if (!data) { if (!data) {
@ -281,83 +175,22 @@
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')
@ -365,28 +198,15 @@
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: 26px; height: 26px; border-radius: 9999px; width: 18px; height: 18px; 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'
@ -399,7 +219,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(instance), .addTo(map),
) )
} }
}) })
@ -414,11 +234,20 @@
highlightMarker = null highlightMarker = null
const coords = geojson?.coordinates const coords = geojson?.coordinates
if (highlightDistance == null || !coords?.length) return if (highlightDistance == null || !coords?.length || !coordDistances.length) return
const at = positionAtDistance(coords, coordDistances, highlightDistance) // Nächstgelegene Koordinate zur gesuchten Distanz
if (!at) return let best = 0
let bestDiff = Infinity
for (let i = 0; i < coordDistances.length; i++) {
const diff = Math.abs(coordDistances[i] - highlightDistance)
if (diff < bestDiff) {
bestDiff = diff
best = i
}
}
const 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;
@ -426,7 +255,9 @@
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 }).setLngLat(at).addTo(map) highlightMarker = new maplibregl.Marker({ element: el })
.setLngLat(coords[idx])
.addTo(map)
}) })
</script> </script>
@ -434,52 +265,11 @@
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}
Popups hängt maplibre selbst in den Karten-Container, deshalb greifen die <p class="text-sm text-muted-foreground mt-2">
Regeln nur global. Bewusst helle Fläche wie das maplibre-Standardpopup — Klicke auf die Karte, um die Stelle zu markieren.
im Dunkelmodus liegt es trotzdem lesbar über der hellen Karte. </p>
--> {/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>

View file

@ -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 [&_tr]:hover:bg-transparent", className)} class={cn("[&_tr]:border-b", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}

View file

@ -1,49 +0,0 @@
/**
* 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',
]

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { parseGpx, haversine, simplify, nearestOnLine, distanceAlong, positionAtDistance } from './gpx' import { parseGpx, haversine, simplify } 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,103 +189,3 @@ 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)
})
})

View file

@ -222,112 +222,3 @@ 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,
]
}

View file

@ -2,7 +2,6 @@ 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')
@ -88,56 +87,14 @@ export class EventStore {
return this.records.find((r) => r.id === id) return this.records.find((r) => r.id === id)
} }
/** get active() {
* Nach Datum sortiert, das nächste zuerst. Events ohne Datum hängen return this.scoped.filter((r) => r.status === 'active')
* 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() {
/** Steht noch an. */ return this.scoped.filter((r) => r.status === 'finished')
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() {

View file

@ -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, UsersResponse } from '$lib/types' import type { RidersResponse } from '$lib/types'
const KEY = Symbol('rider') const KEY = Symbol('rider')
@ -34,15 +34,6 @@ 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
@ -111,34 +102,6 @@ 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)
} }

View file

@ -12,13 +12,6 @@ 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 {
@ -175,63 +168,6 @@ 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 {

View file

@ -1,22 +0,0 @@
/**
* 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
},
}
}

View file

@ -2,7 +2,6 @@ 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')
@ -14,22 +13,18 @@ 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() : now const end = t.stop ? new Date(t.stop).getTime() : Date.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: formatDuration(duration), formattedTime: `${minutes}:${seconds.padStart(4, '0')}`,
} }
} }
@ -128,29 +123,12 @@ export class TimeStore {
}) })
} }
getByRun(runId: string, now = Date.now()): TimeWithDuration[] { getByRun(runId: string): TimeWithDuration[] {
return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now)) return this.scoped.filter((r) => r.run === runId).map(enrich)
} }
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] { getByRider(riderId: string): TimeWithDuration[] {
return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now)) return this.scoped.filter((r) => r.rider === riderId).map(enrich)
}
/** 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) {
@ -166,23 +144,11 @@ export class TimeStore {
} }
get running(): TimeWithDuration[] { get running(): TimeWithDuration[] {
return this.allRunning() return this.scoped.filter((r) => r.status === 'active').map(enrich)
}
/**
* 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[] {
const now = Date.now() return this.scoped.filter((r) => r.status === 'finished').map(enrich)
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
} }
getLeaderboard(runId: string): TimeWithDuration[] { getLeaderboard(runId: string): TimeWithDuration[] {
@ -191,12 +157,6 @@ 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()

View file

@ -105,19 +105,14 @@ 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(teamId = this.teams.activeId): Promise<number> { async seedDefaults(): Promise<number> {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team') if (!teamId) throw new Error('Kein aktives Team')
// Beim Anlegen eines Teams ist man dessen Owner, aber der Datensatz if (!this.canManage) {
// 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( const existing = new Set(this.scoped.map((f) => f.label))
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) {

View file

@ -1,20 +1,12 @@
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, UsersResponse } from '$lib/types' import type { TrailMarkersResponse } 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<TrailMarker[]>([]) records = $state<TrailMarkersResponse[]>([])
loading = $state(false) loading = $state(false)
error = $state<string | null>(null) error = $state<string | null>(null)
@ -22,14 +14,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): TrailMarker[] { byTrail(trailId: string): TrailMarkersResponse[] {
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): TrailMarker[] { openByTrail(trailId: string): TrailMarkersResponse[] {
return this.byTrail(trailId).filter((r) => !r.resolved) return this.byTrail(trailId).filter((r) => !r.resolved)
} }
@ -38,7 +30,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: TrailMarker, stewards: string[], teamOwner?: string, teamAdmins: string[] = []): boolean { canEdit(marker: TrailMarkersResponse, 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
@ -50,25 +42,12 @@ 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) {
@ -82,31 +61,16 @@ 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) => {
// Realtime liefert den Datensatz ungetypt; angefordert wird unten const idx = this.records.findIndex((r) => r.id === e.record.id)
// dasselbe expand wie beim Laden. if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records]
const record = e.record as TrailMarker else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
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)
}, { expand: 'created_by' }).then((unsub) => { }).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')
@ -118,7 +82,7 @@ export class TrailMarkerStore {
}) })
} }
async edit(id: string, data: Partial<TrailMarker>) { async edit(id: string, data: Partial<TrailMarkersResponse>) {
return await api.collection('trail_markers').update(id, data) return await api.collection('trail_markers').update(id, data)
} }

View file

@ -92,18 +92,6 @@ 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 })
} }

View file

@ -1,72 +0,0 @@
/**
* 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}`
}

View file

@ -1,38 +0,0 @@
/**
* 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
}

View file

@ -46,15 +46,19 @@ 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
starts?: IsoDateString status?: EventsStatusOptions
team?: RecordIdString team?: RecordIdString
updated?: IsoDateString updated?: IsoDateString
} }
@ -70,7 +74,6 @@ export type RidersRecord = {
number?: string number?: string
team?: RecordIdString team?: RecordIdString
updated?: IsoDateString updated?: IsoDateString
user?: RecordIdString
} }
export type RunsRecord = { export type RunsRecord = {
@ -144,10 +147,8 @@ 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
@ -187,8 +188,6 @@ 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
@ -202,7 +201,6 @@ 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

View file

@ -15,9 +15,7 @@
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 Avatar from '$lib/components/Avatar.svelte' import { Home, Calendar, Flag, Timer, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-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'
@ -84,14 +82,13 @@
}) })
} }
// 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/settings', icon: Settings, label: 'Einstellungen' }, { href: '/dashboard/timing', icon: Timer, label: 'Zeitnahme' },
{ href: '/dashboard/results', icon: Flag, label: 'Ergebnisse' },
{ href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' },
] ]
</script> </script>
@ -99,12 +96,20 @@
<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 min-h-16 items-center justify-between gap-4 py-2"> <div class="flex h-16 items-center justify-between">
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in <div class="flex items-center gap-2">
voller Groesse wuerde es die 64 px hohe Leiste <Logo />
ueberragen und beschnitten wirken. --> <div class="hidden md:block">
<div class="flex items-center gap-3 min-w-0"> <TeamSwitcher />
<Logo class="h-11 w-11 shrink-0" /> </div>
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
</div>
<nav class="hidden md:flex items-center gap-1"> <nav class="hidden md:flex items-center gap-1">
{#each nav as item} {#each nav as item}
@ -118,54 +123,16 @@
</a> </a>
{/each} {/each}
</nav> </nav>
</div>
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
Zeile, das Team-Dropdown darunter. -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Wer man ist, steht links; was man tun kann, rechts. --> <div class="hidden md:flex items-center gap-2">
<div class="hidden md:flex min-w-56 flex-col gap-1"> <div class="text-sm text-right">
<div class="flex items-center justify-between gap-3"> <p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
<a <p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>
href="/dashboard/settings" </div>
title="Profil" <Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
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" /> <LogOut class="h-4 w-4" />
</Button> </Button>
</span>
</div>
<div class="flex">
<TeamSwitcher />
</div>
</div> </div>
<Button <Button
@ -200,29 +167,15 @@
</a> </a>
{/each} {/each}
<Separator class="my-2" /> <Separator class="my-2" />
<div class="flex items-center justify-between gap-2 px-3 py-2"> <div class="flex items-center justify-between px-3 py-2">
<div class="flex flex-col items-start gap-1 min-w-0"> <div class="text-sm">
<span class="flex items-center gap-2 min-w-0"> <p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
<Avatar user={auth.user} size={24} /> <p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>
<span class="text-sm font-medium leading-none truncate">
{auth.user.name || auth.user.email}
</span>
</span>
<TeamSwitcher />
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <Button variant="ghost" size="icon" onclick={handleLogout}>
<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" /> <LogOut class="h-4 w-4" />
</Button> </Button>
</div> </div>
</div>
</nav> </nav>
</div> </div>
{/if} {/if}
@ -231,8 +184,5 @@
<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}

View file

@ -8,8 +8,7 @@
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 { formatDateRange } from '$lib/time' import { Calendar, Users, Flag, Timer, Clock, MapPin } from 'lucide-svelte'
import { Calendar, Users, Clock, MapPin, Route, UsersRound } from 'lucide-svelte'
const teams = getTeamContext() const teams = getTeamContext()
const events = getEventContext() const events = getEventContext()
@ -17,10 +16,6 @@
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,
@ -42,7 +37,7 @@
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
</p> </p>
{:else} {:else}
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/settings/team" class="underline">Team verwalten</a>.</p> <p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/teams" class="underline">Team verwalten</a>.</p>
{/if} {/if}
</div> </div>
@ -54,7 +49,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} heute</p> <p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} aktiv</p>
</CardContent> </CardContent>
</Card> </Card>
@ -92,48 +87,44 @@
</a> </a>
</Card> </Card>
<Card class="hover:border-primary transition-colors"> <Card class="hover:border-primary transition-colors">
<a href="/dashboard/trails" class="block p-6"> <a href="/dashboard/timing" class="block p-6">
<Route class="h-6 w-6 text-muted-foreground mb-3" /> <Timer class="h-6 w-6 text-muted-foreground mb-3" />
<h3 class="font-semibold">Trails</h3> <h3 class="font-semibold">Zeitnahme</h3>
<p class="text-sm text-muted-foreground mt-1">Strecken, Zustand und Meldungen</p> <p class="text-sm text-muted-foreground mt-1">Zeiten starten und stoppen</p>
</a> </a>
</Card> </Card>
<Card class="hover:border-primary transition-colors"> <Card class="hover:border-primary transition-colors">
<a href="/dashboard/settings/team" class="block p-6"> <a href="/dashboard/results" class="block p-6">
<UsersRound class="h-6 w-6 text-muted-foreground mb-3" /> <Flag class="h-6 w-6 text-muted-foreground mb-3" />
<h3 class="font-semibold">Teams</h3> <h3 class="font-semibold">Ergebnisse</h3>
<p class="text-sm text-muted-foreground mt-1">Mitglieder und Rechte</p> <p class="text-sm text-muted-foreground mt-1">Zeiten und Rankings</p>
</a> </a>
</Card> </Card>
</div> </div>
</div> </div>
<div> <div>
<h2 class="text-xl font-semibold mb-4">Heute und demnächst</h2> <h2 class="text-xl font-semibold mb-4">Aktive Events</h2>
{#if upNext.length === 0} {#if events.active.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">Nichts geplant</p> <p class="font-medium">Keine aktiven Events</p>
<p class="text-sm text-muted-foreground mt-1"> <p class="text-sm text-muted-foreground mt-1">
Trag einem Event ein Datum ein, dann steht es hier. Aktiviere ein Event über die Eventliste, um mit der Zeitnahme zu beginnen.
</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 upNext as event} {#each events.active 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>
{#if event.starts} <Badge>Aktiv</Badge>
<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">
@ -144,7 +135,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/events/{event.id}" size="sm">Zum Event</Button> <Button href="/dashboard/timing?event={event.id}" size="sm">Zeitnahme</Button>
</CardContent> </CardContent>
</Card> </Card>
{/each} {/each}

View file

@ -7,24 +7,25 @@
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 { dayOf, formatDateRange } from '$lib/time' import * as Select from '@/components/ui/select'
import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte' import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte'
import type { EventsResponse } from '$lib/types' import type { EventsResponse, EventsStatusOptions } 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({ name: '', description: '', location: '', starts: '', ends: '' }) let form = $state<{
let multiDay = $state(false) name: string
let formError = $state<string | null>(null) description: string
location: string
status: EventsStatusOptions
}>({ name: '', description: '', location: '', status: 'draft' as EventsStatusOptions })
function openCreate() { function openCreate() {
editing = null editing = null
form = { name: '', description: '', location: '', starts: '', ends: '' } form = { name: '', description: '', location: '', status: 'draft' as EventsStatusOptions }
multiDay = false
formError = null
showDialog = true showDialog = true
} }
@ -34,47 +35,29 @@
name: e.name ?? '', name: e.name ?? '',
description: e.description ?? '', description: e.description ?? '',
location: e.location ?? '', location: e.location ?? '',
starts: dayOf(e.starts), status: e.status ?? ('draft' as EventsStatusOptions),
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 {
// Ein eintägiges Event hat kein Ende — sonst stünde überall if (editing) await events.edit(editing.id, form)
// dasselbe Datum zweimal. else await events.create(form)
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) {
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.' alert(`Fehler: ${e.message}`)
} }
} }
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: 'Läuft gerade', items: events.active }, { title: 'Aktive Events', items: events.active, status: 'active' as const, showZero: false },
{ title: 'Anstehend', items: events.upcoming }, { title: 'Entwürfe', items: events.drafts, status: 'draft' as const, showZero: false },
{ title: 'Ohne Termin', items: events.undated }, { title: 'Abgeschlossen', items: events.finished, status: 'finished' as const, showZero: false },
{ title: 'Vorbei', items: events.past },
]) ])
</script> </script>
@ -119,11 +102,9 @@
<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>
{#if event.starts} <Badge variant={statusVariants[event.status as keyof typeof statusVariants] ?? 'secondary'}>
<Badge variant="secondary" class="shrink-0"> {statusLabels[event.status as keyof typeof statusLabels] ?? 'Entwurf'}
{formatDateRange(event.starts, event.ends)}
</Badge> </Badge>
{/if}
</div> </div>
{#if event.location} {#if event.location}
<CardDescription class="flex items-center gap-1"> <CardDescription class="flex items-center gap-1">
@ -187,30 +168,19 @@
<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 for="event-starts">Datum</Label> <Label>Status</Label>
<Input id="event-starts" type="date" bind:value={form.starts} /> <Select.Root type="single" bind:value={form.status}>
<Select.Trigger>
{statusLabels[form.status]}
</Select.Trigger>
<Select.Content>
<Select.Item value="draft">Entwurf</Select.Item>
<Select.Item value="active">Aktiv</Select.Item>
<Select.Item value="finished">Abgeschlossen</Select.Item>
</Select.Content>
</Select.Root>
</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>

View file

@ -1,12 +1,8 @@
<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'
@ -24,31 +20,18 @@
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)
@ -142,12 +125,9 @@
</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>
{#if event.starts} <Badge variant={event.status === 'active' ? 'default' : event.status === 'finished' ? 'outline' : 'secondary'}>
<Badge variant="secondary" class="gap-1"> {event.status === 'active' ? 'Aktiv' : event.status === 'finished' ? 'Abgeschlossen' : 'Entwurf'}
<Calendar class="h-3 w-3" />
{formatDateRange(event.starts, event.ends)}
</Badge> </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">
@ -159,14 +139,18 @@
<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">
<Button {#if event.status === 'active'}
onclick={() => (timeDialog = true)} <Button href="/dashboard/timing?event={event.id}">
disabled={eventRiders.length === 0 || eventRuns.length === 0}
>
<Timer class="h-4 w-4 mr-2" /> <Timer class="h-4 w-4 mr-2" />
Zeit erfassen Zeitnahme
</Button> </Button>
{/if}
<Button href="/dashboard/results?event={event.id}" variant="outline">
<Flag class="h-4 w-4 mr-2" />
Ergebnisse
</Button>
</div>
</div> </div>
<Separator /> <Separator />
@ -180,14 +164,12 @@
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">Klick auf einen Fahrer zeigt seine Runs</p> <p class="text-sm text-muted-foreground">Teilnehmer dieses Events</p>
</div> </div>
{#if riders.canManage}
<Button onclick={openRiderCreate}> <Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" /> <Plus class="h-4 w-4 mr-2" />
Fahrer hinzufügen Fahrer hinzufügen
</Button> </Button>
{/if}
</div> </div>
<Card> <Card>
@ -196,17 +178,11 @@
<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>
{#if riders.canManage}
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p> <p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
<Button onclick={openRiderCreate}> <Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" /> <Plus class="h-4 w-4 mr-2" />
Ersten Fahrer hinzufügen Ersten Fahrer hinzufügen
</Button> </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>
@ -214,39 +190,29 @@
<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)}
{@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length} <Table.Row>
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(rider.id))}>
<Table.Cell> <Table.Cell>
<Badge variant="outline">{rider.number || '—'}</Badge> <Badge variant="outline" class="font-mono">{rider.number || '—'}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
<a href={riderPath(rider.id)} class="hover:underline">
{#if fullName(rider)} {#if fullName(rider)}
{fullName(rider)} {fullName(rider)}
{:else} {:else}
<span class="text-muted-foreground italic">— ohne Name —</span> <span class="text-muted-foreground italic">— ohne Name —</span>
{/if} {/if}
</a>
</Table.Cell> </Table.Cell>
<Table.Cell class="text-muted-foreground text-sm"> <Table.Cell class="text-right">
{done} von {eventRuns.length} Runs
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
{#if riders.canManage}
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}> <Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}> <Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
</Button> </Button>
{/if}
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
@ -268,7 +234,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">Klick auf einen Run öffnet die Zeitnahme</p> <p class="text-sm text-muted-foreground">Einzelne Läufe/Stages des Events</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" />
@ -293,26 +259,14 @@
<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)}
{@const runTimes = times.getByRun(run.id)} <Table.Row>
{@const active = runTimes.filter((t) => t.status === 'active').length} <Table.Cell class="font-medium">{run.description}</Table.Cell>
{@const ranked = runTimes.filter((t) => t.status === 'finished').length} <Table.Cell class="text-right">
<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>
@ -396,5 +350,3 @@
</form> </form>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} />

View file

@ -1,242 +0,0 @@
<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}
/>

View file

@ -1,245 +0,0 @@
<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} />

View file

@ -0,0 +1,203 @@
<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>

View file

@ -1,143 +0,0 @@
<script lang="ts">
/**
* Fahrerverzeichnis des Teams — für alle sichtbar, für niemanden hier
* änderbar. Anlegen, Bearbeiten und Kontoverknüpfung liegen in den
* Einstellungen und damit bei der Teamleitung; serverseitig setzen das
* die create/update/deleteRule der Collection durch.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { 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>

View file

@ -1,57 +0,0 @@
<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>

View file

@ -1,190 +0,0 @@
<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>

View file

@ -7,8 +7,6 @@
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'
@ -106,9 +104,10 @@
} }
</script> </script>
<div class="space-y-4"> <div class="space-y-6">
<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>
@ -150,7 +149,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">
<FlagIcon name={flag.icon} color={flag.color} class="size-5" /> <span class="size-4 rounded-full shrink-0" style:background={flag.color}></span>
<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}
@ -202,8 +201,13 @@
{/each} {/each}
</div> </div>
</div> </div>
<IconPicker id="flag-icon" bind:value={form.icon} color={form.color} /> <div class="space-y-2">
<Label for="flag-icon">Icon</Label>
<Input id="flag-icon" bind:value={form.icon} placeholder="Lucide-Name, z. B. TreePine" />
<p class="text-xs text-muted-foreground">
Name eines Icons von lucide.dev, in Schreibweise wie „TreePine".
</p>
</div>
{#if error} {#if error}
<p class="text-sm text-destructive">{error}</p> <p class="text-sm text-destructive">{error}</p>
{/if} {/if}

View file

@ -1,385 +0,0 @@
<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>

View file

@ -1,347 +0,0 @@
<script lang="ts">
/**
* Team-Verwaltung. Owner und Admins ändern hier die Team-Daten und die
* Mitglieder; alle anderen sehen dieselbe Liste ohne Knöpfe.
*/
import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { 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>

View file

@ -0,0 +1,341 @@
<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>

View file

@ -0,0 +1,281 @@
<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>

View file

@ -4,7 +4,6 @@
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'
@ -23,6 +22,18 @@
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 */
@ -108,19 +119,14 @@
{#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>
<span <Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs" {STATUS_LABEL[trail.status] ?? trail.status}
style="color: {status.color}; border-color: {status.color}" </Badge>
>
<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">
@ -129,7 +135,6 @@
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}

View file

@ -9,28 +9,21 @@
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 { TRAIL_STATUS, trailStatus } from '$lib/trailStatus' import { Separator } from '@/components/ui/separator'
import { import {
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown, ArrowLeft, Upload, MapPin, MessageSquare, History,
Check, Trash2, Download, Plus, Ruler, TrendingUp, Check, Trash2, Download, Plus, AlertTriangle,
} 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, UsersResponse } from '$lib/types' import type { TrailCommentsResponse, TrailVersionsResponse } 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.
@ -72,54 +65,23 @@
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) : [])
const NEUTRAL = '#64748b' // Für die Karte aufbereitete Marker
/**
* 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 look = markerLook(m) const flag = flags.getById(m.flag)
return { return {
id: m.id, id: m.id,
lat: m.lat, lat: m.lat,
lng: m.lng, lng: m.lng,
color: look.color, color: flag?.color ?? '#64748b',
resolved: !!m.resolved, resolved: !!m.resolved,
label: look.label, label: flag?.label ?? 'Marker',
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)
@ -150,88 +112,26 @@
} }
// --- Marker setzen --------------------------------------------------- // --- Marker setzen ---------------------------------------------------
// Die Koordinaten kommen bereits auf die Linie projiziert aus der Karte; let placing = $state(false)
// 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 markerDistance = $state<number | null>(null) let markerFlag = $state('')
// 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)
// Ohne Flag-Typen geht es trotzdem: Dann ist jede Meldung eine eigene. function onPlace(coords: { lat: number; lng: number }) {
const canPlace = $derived(!!trail) markerCoords = coords
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) {
if (!markerFlag && !markerLabel.trim()) { markerError = 'Bitte einen Flag-Typ wählen.'
markerError = 'Bitte einen Typ wählen oder eine eigene Bezeichnung eintragen.'
return return
} }
@ -239,17 +139,12 @@
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.'
} }
@ -257,9 +152,7 @@
// --- Kommentare ------------------------------------------------------ // --- Kommentare ------------------------------------------------------
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht. // Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }> let comments = $state<TrailCommentsResponse[]>([])
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
@ -271,30 +164,20 @@
let cancelled = false let cancelled = false
api.collection('trail_comments') api.collection('trail_comments')
.getFullList({ .getFullList({ filter: `trail="${id}"`, sort: '-created', requestKey: null })
filter: `trail="${id}"`,
sort: '-created',
expand: 'created_by',
requestKey: null,
})
.then((r) => { .then((r) => {
if (!cancelled) comments = r as Comment[] if (!cancelled) comments = r
}) })
.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( .subscribe('*', (e) => {
'*',
(e) => {
if (e.record.trail !== id) return if (e.record.trail !== id) return
const record = e.record as Comment const idx = comments.findIndex((c) => c.id === e.record.id)
const idx = comments.findIndex((c) => c.id === record.id) if (e.action === 'create' && idx === -1) comments = [e.record, ...comments]
if (e.action === 'create' && idx === -1) comments = [record, ...comments] else if (e.action === 'update' && idx !== -1) comments[idx] = e.record
else if (e.action === 'update' && idx !== -1) comments[idx] = record else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== e.record.id)
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
@ -326,18 +209,25 @@
} }
// --- 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 || status === trail.status) return if (!trail) 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>
@ -352,89 +242,47 @@
{:else} {:else}
<div class="space-y-6"> <div class="space-y-6">
<!-- Kopf --> <!-- Kopf -->
<div class="space-y-2"> <div class="flex items-start justify-between gap-4">
<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">
<h1 class="text-4xl font-bold tracking-tight">{trail.name}</h1>
{#if canEdit}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<!-- Farbe kommt aus derselben Quelle wie die Linie auf der Karte. -->
<Button
{...props}
variant="outline"
size="sm"
class="gap-2"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
<ChevronDown class="size-3 opacity-60" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-60">
<DropdownMenu.Label>Zustand melden</DropdownMenu.Label>
{#each TRAIL_STATUS as s (s.value)}
<DropdownMenu.Item
onclick={() => setStatus(s.value)}
class={s.value === trail.status ? 'bg-accent' : ''}
>
<span class="size-2 rounded-full mr-2 shrink-0" style:background={s.color}></span>
<span class="flex-1">{s.label}</span>
<span class="text-xs text-muted-foreground">{s.hint}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<span
class="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
</span>
{/if}
{#if version}
<span class="flex items-center gap-4 text-sm text-muted-foreground">
<span class="flex items-center gap-1.5">
<Ruler class="size-4" />
<strong class="text-foreground">{(version.distance_m / 1000).toFixed(1)}</strong> km
</span>
<span class="flex items-center gap-1.5">
<TrendingUp class="size-4" />
<strong class="text-foreground">{version.ascent_m}</strong> hm
</span>
</span>
{/if}
</div>
{#if trail.status_changed}
<p class="text-xs text-muted-foreground">
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
gemeldet am {formatDate(trail.status_changed)}
{#if trail.status_by}
von {teams.userLabel(trail.status_by)}
{/if}
</p>
{/if}
{#if safeDescription} {#if safeDescription}
<p class="text-muted-foreground text-sm">{@html safeDescription}</p> <p class="text-muted-foreground text-sm">{@html safeDescription}</p>
{/if} {/if}
</div> </div>
<div class="grid gap-6 lg:grid-cols-3 items-start"> <div class="flex items-center gap-2">
<!-- Karte und Höhenprofil --> <Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
<Card class="lg:col-span-2"> {STATUS_LABEL[trail.status] ?? trail.status}
</Badge>
{#if canEdit}
<Button variant="outline" size="sm" onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
</div>
</div>
{#if canEdit}
<div class="flex gap-2">
{#each ['offen', 'eingeschraenkt', 'gesperrt'] as s (s)}
<Button
variant={trail.status === s ? 'default' : 'outline'}
size="sm"
onclick={() => setStatus(s)}
>
{STATUS_LABEL[s as keyof typeof STATUS_LABEL]}
</Button>
{/each}
</div>
{/if}
<!-- Karte -->
<Card>
<CardContent class="pt-6 space-y-4"> <CardContent class="pt-6 space-y-4">
{#if version?.geojson} {#if version?.geojson}
<TrailMap <TrailMap
@ -442,18 +290,39 @@
bounds={version.bounds} bounds={version.bounds}
markers={mapMarkers} markers={mapMarkers}
status={trail.status} status={trail.status}
{placing}
coordDistances={version.coord_distances ?? []} coordDistances={version.coord_distances ?? []}
highlightDistance={highlight} highlightDistance={highlight}
onplace={canPlace ? onPlace : undefined} onplace={onPlace}
ontrailhover={(d) => (highlight = d)}
/> />
<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} {#if version.elevation?.length}
<Separator />
<ElevationProfile <ElevationProfile
elevation={version.elevation} elevation={version.elevation}
highlightDistance={highlight} onhover={(i) => (highlight = i)}
onhover={(d) => (highlight = d)}
onselect={canPlace ? placeFromProfile : undefined}
/> />
{/if} {/if}
{:else} {:else}
@ -471,44 +340,63 @@
</CardContent> </CardContent>
</Card> </Card>
<!-- Marker, Kommentare, Versionen --> <!-- Reiter -->
<div class="space-y-4"> <div class="flex gap-2 border-b">
<Card> <button
<CardHeader class="pb-3"> class="px-4 py-2 text-sm border-b-2 -mb-px"
<CardTitle class="flex items-center gap-2 text-base"> class:border-primary={tab === 'marker'}
<MapPin class="size-4 text-primary" /> class:border-transparent={tab !== 'marker'}
onclick={() => (tab = 'marker')}
>
<MapPin class="size-4 inline mr-1" />
Marker ({trailMarkers.length}) Marker ({trailMarkers.length})
</CardTitle> </button>
</CardHeader> <button
<CardContent class="space-y-3"> class="px-4 py-2 text-sm border-b-2 -mb-px"
<p class="text-xs text-muted-foreground"> class:border-primary={tab === 'kommentare'}
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen class:border-transparent={tab !== 'kommentare'}
Marker zu setzen. onclick={() => (tab = 'kommentare')}
</p> >
<MessageSquare class="size-4 inline mr-1" />
Kommentare ({comments.length})
</button>
<button
class="px-4 py-2 text-sm border-b-2 -mb-px"
class:border-primary={tab === 'versionen'}
class:border-transparent={tab !== 'versionen'}
onclick={() => (tab = 'versionen')}
>
<History class="size-4 inline mr-1" />
Versionen ({trailVersions.length})
</button>
</div>
{#if tab === 'marker'}
{#if trailMarkers.length === 0} {#if trailMarkers.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p> <p class="text-muted-foreground text-sm">
Noch keine Marker. Setze einen über „Marker setzen" auf der Karte.
</p>
{:else} {:else}
<ul class="divide-y -mx-2"> <div class="space-y-2">
{#each trailMarkers as m (m.id)} {#each trailMarkers as m (m.id)}
{@const look = markerLook(m)} {@const flag = flags.getById(m.flag)}
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}> <Card class={m.resolved ? 'opacity-60' : ''}>
<FlagIcon name={look.icon} color={look.color} class="size-5" /> <CardContent class="py-3 flex items-center gap-3">
<span
class="size-3 rounded-full shrink-0"
style:background={flag?.color ?? '#64748b'}
></span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium">{look.label}</p> <p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
{#if m.note} {#if m.note}
<p class="text-sm text-muted-foreground">{m.note}</p> <p class="text-sm text-muted-foreground">{m.note}</p>
{/if} {/if}
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">{formatDate(m.created)}</p>
{formatDate(m.created)} · {markers.authorName(m)}
</p>
</div> </div>
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])} {#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
<div class="flex shrink-0">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
class="size-8"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'} title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => { onclick={async () => {
try { try {
@ -520,32 +408,14 @@
> >
<Check class="size-4" /> <Check class="size-4" />
</Button> </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}
</li>
{/each}
</ul>
{/if} {/if}
</CardContent> </CardContent>
</Card> </Card>
{/each}
<Card> </div>
<CardHeader class="pb-3"> {/if}
<CardTitle class="flex items-center gap-2 text-base"> {:else if tab === 'kommentare'}
<MessageSquare class="size-4 text-primary" /> <div class="space-y-4">
Kommentare ({comments.length})
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex gap-2"> <div class="flex gap-2">
<Input <Input
bind:value={commentText} bind:value={commentText}
@ -561,64 +431,28 @@
{/if} {/if}
{#if comments.length === 0} {#if comments.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Kommentare.</p> <p class="text-muted-foreground text-sm">Noch keine Kommentare.</p>
{:else} {:else}
<ul class="divide-y -mx-2"> <div class="space-y-2">
{#each comments as c (c.id)} {#each comments as c (c.id)}
{@const author = c.expand?.created_by} <Card>
<li class="flex gap-3 px-2 py-3"> <CardContent class="py-3">
<!-- Nur das Bild; wer dahintersteckt, sagt der Tooltip. -->
<span
use:tooltip={{
content: author?.name || author?.email || 'Unbekannt',
}}
>
<Avatar user={author} size={28} />
</span>
<div class="min-w-0 flex-1">
<p class="text-sm">{c.text}</p> <p class="text-sm">{c.text}</p>
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p> <p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
</div>
</li>
{/each}
</ul>
{/if}
</CardContent> </CardContent>
</Card> </Card>
{/each}
<Card> </div>
<!-- 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}
</div>
{#if trailVersions.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Version hochgeladen.</p>
{:else} {:else}
<ul class="divide-y -mx-2"> {#if trailVersions.length === 0}
<p class="text-muted-foreground text-sm">Noch keine Version hochgeladen.</p>
{:else}
<div class="space-y-2">
{#each trailVersions as v (v.id)} {#each trailVersions as v (v.id)}
<li class="px-2 py-3 flex items-start gap-2"> <Card class={v.id === trail.current ? 'border-primary' : ''}>
<CardContent class="py-3 flex items-center gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<p class="text-sm font-medium">{formatDate(v.created)}</p> <p class="text-sm font-medium">{formatDate(v.created)}</p>
@ -626,32 +460,33 @@
<Badge>Aktiv</Badge> <Badge>Aktiv</Badge>
{/if} {/if}
</div> </div>
<p class="text-xs text-muted-foreground"> <p class="text-sm text-muted-foreground">
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm {(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
{#if v.note}· {v.note}{/if} {#if v.note}· {v.note}{/if}
</p> </p>
</div> </div>
{#if v.gpx} {#if v.gpx}
<Button variant="ghost" size="icon" class="size-8" href={getFileURL(v, v.gpx)} title="GPX herunterladen"> <Button variant="ghost" size="sm" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
<Download class="size-4" /> <Download class="size-4" />
</Button> </Button>
{/if} {/if}
{#if canEdit && v.id !== trail.current} {#if canEdit && v.id !== trail.current}
<Button variant="outline" size="sm" onclick={() => versions.activate(trail.id, v.id)}> <Button
variant="outline"
size="sm"
onclick={() => versions.activate(trail.id, v.id)}
>
Aktivieren Aktivieren
</Button> </Button>
{/if} {/if}
</li>
{/each}
</ul>
{/if}
</CardContent> </CardContent>
{/if}
</Card> </Card>
{/each}
</div> </div>
</div> {/if}
{/if}
</div> </div>
{/if} {/if}
@ -699,47 +534,24 @@
<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>
<Select.Root type="single" value={markerFlag} onValueChange={selectFlag}> <div class="flex flex-wrap gap-2">
<Select.Trigger class="w-full">
{#if selectedFlag}
<span class="flex items-center gap-2">
<span class="size-2 rounded-full" style:background={selectedFlag.color}></span>
{selectedFlag.label}
</span>
{:else}
Eigener Typ
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value={CUSTOM}>Eigener Typ …</Select.Item>
{#each flags.scoped as f (f.id)} {#each flags.scoped as f (f.id)}
<Select.Item value={f.id}> <Button
<span class="size-2 rounded-full mr-2 shrink-0" style:background={f.color}></span> variant={markerFlag === f.id ? 'default' : 'outline'}
size="sm"
onclick={() => (markerFlag = f.id)}
>
<span class="size-2 rounded-full mr-2" style:background={f.color}></span>
{f.label} {f.label}
</Select.Item> </Button>
{/each} {/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> </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" />