Beim Test aufgefallen: Wer im Funkloch stoppte, sah die Zeit weiter als laufend — der Ausgang hatte den Stopp, der Datensatz nicht. Ein zweiter Druck legte einen zweiten Stopp daneben, und beim Uebertragen gewann der letzte. Welche Zeit am Ende zaehlte, war nicht mehr zu sagen. Drei Dinge dagegen. Ein Stopp im Ausgang traegt sich sofort auch am Datensatz ein, die Zeit ist damit sichtbar beendet. Ein zweiter Stopp fuer dieselbe Zeit wird abgewiesen, mit Ansage statt still. Und ist der Ausgang leer, laedt der Store einmal neu und tauscht die vorlaeufigen Zeiten gegen die echten samt ihren IDs. Dabei fiel die groessere Luecke auf: Ein offline gestarteter Lauf stand nirgends und liess sich deshalb gar nicht stoppen — die Oberflaeche kennt nur, was im Store steht. Er bekommt jetzt einen vorlaeufigen Datensatz mit einer local:-ID, laeuft sichtbar mit und laesst sich stoppen, bevor der Server ueberhaupt von ihm weiss. Dazu der Verbindungswaechter: /api/health im Takt, Symbol in der Kopfleiste und eine Meldung, die stehen bleibt, bis der Server wieder antwortet. navigator.onLine allein taugt dafuer nicht — im WLAN ohne Uplink und hinter einem Portal steht es unbeirrt auf true. Kehrt die Verbindung zurueck, geht der Ausgang von selbst raus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
410 lines
20 KiB
Svelte
410 lines
20 KiB
Svelte
<script lang="ts">
|
|
import { auth } from '$lib/stores/pocketbase.svelte'
|
|
import { goto } from '$app/navigation'
|
|
import { page } from '$app/state'
|
|
import { tooltip } from '$lib/stores/app.svelte'
|
|
import { setTeamContext } from '$lib/stores/teams.svelte'
|
|
import { setTeamInviteContext } from '$lib/stores/teamInvites.svelte'
|
|
import { setEventContext } from '$lib/stores/events.svelte'
|
|
import { setStageContext } from '$lib/stores/stages.svelte'
|
|
import { setRiderContext } from '$lib/stores/riders.svelte'
|
|
import { setTimeContext } from '$lib/stores/times.svelte'
|
|
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
import { setEventSeriesContext } from '$lib/stores/eventSeries.svelte'
|
|
import { setTrailContext } from '$lib/stores/trails.svelte'
|
|
import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte'
|
|
import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte'
|
|
import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
|
|
import { setTrailCommentContext } from '$lib/stores/trailComments.svelte'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import Logo from '$lib/components/Logo.svelte'
|
|
import TeamMenu from '$lib/components/TeamMenu.svelte'
|
|
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
|
|
import OfflineBanner from '$lib/components/OfflineBanner.svelte'
|
|
import { connection } from '$lib/stores/connection.svelte'
|
|
import * as NavigationMenu from '@/components/ui/navigation-menu'
|
|
import Avatar from '$lib/components/Avatar.svelte'
|
|
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
|
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Plus, Route, Settings, Users, Wifi, WifiOff } from 'lucide-svelte'
|
|
import { toggleMode, mode } from 'mode-watcher'
|
|
import { onDestroy, onMount } from 'svelte'
|
|
|
|
let { children } = $props()
|
|
|
|
// Teams MUSS vor den anderen Stores stehen, weil die anderen Stores
|
|
// getTeamContext() im Constructor aufrufen
|
|
const teams = setTeamContext()
|
|
const invites = setTeamInviteContext()
|
|
const events = setEventContext()
|
|
const stages = setStageContext()
|
|
const riders = setRiderContext()
|
|
const times = setTimeContext()
|
|
// Nach events und riders: Der Store greift im Constructor auf beide zu.
|
|
const participants = setEventParticipantContext()
|
|
const series = setEventSeriesContext()
|
|
const trails = setTrailContext()
|
|
const trailVersions = setTrailVersionContext()
|
|
const trailFlags = setTrailFlagContext()
|
|
const trailMarkers = setTrailMarkerContext()
|
|
const trailComments = setTrailCommentContext()
|
|
|
|
let isMobileMenuOpen = $state(false)
|
|
|
|
// Der Dialog gehoert nicht ins mobile Menue: Das schliesst sich beim
|
|
// Klick, und der Dialog verschwaende mit ihm.
|
|
let createTeamDialog = $state(false)
|
|
|
|
onMount(async () => {
|
|
if (!auth.user) {
|
|
goto('/login', { replaceState: true })
|
|
return
|
|
}
|
|
// Teams zuerst laden, weil die anderen Stores nach team filtern
|
|
await teams.load()
|
|
teams.subscribe()
|
|
await Promise.all([
|
|
events.load(), stages.load(), riders.load(), times.load(), participants.load(), series.load(),
|
|
invites.load(),
|
|
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
|
|
trailComments.load(),
|
|
])
|
|
// Die Verbindungsprüfung laeuft, solange das Dashboard steht: Sie
|
|
// ist die Grundlage dafuer, dass niemand ins Leere stoppt.
|
|
connection.start()
|
|
|
|
invites.subscribe()
|
|
events.subscribe()
|
|
stages.subscribe()
|
|
riders.subscribe()
|
|
times.subscribe()
|
|
participants.subscribe()
|
|
series.subscribe()
|
|
trails.subscribe()
|
|
trailVersions.subscribe()
|
|
trailFlags.subscribe()
|
|
trailMarkers.subscribe()
|
|
trailComments.subscribe()
|
|
})
|
|
|
|
onDestroy(() => {
|
|
connection.stop()
|
|
teams.destroy()
|
|
invites.destroy()
|
|
events.destroy()
|
|
stages.destroy()
|
|
riders.destroy()
|
|
times.destroy()
|
|
participants.destroy()
|
|
series.destroy()
|
|
trails.destroy()
|
|
trailVersions.destroy()
|
|
trailFlags.destroy()
|
|
trailMarkers.destroy()
|
|
trailComments.destroy()
|
|
})
|
|
|
|
// Ohne Rückfrage: Abmelden löscht nichts und ist mit einem Login wieder
|
|
// rückgängig gemacht — eine Bestätigung wäre nur ein zusätzlicher Klick.
|
|
async function handleLogout() {
|
|
await auth.logout()
|
|
goto('/login')
|
|
}
|
|
|
|
// Ein Menuepunkt sieht aus wie der andere, ob Link oder aufklappbar.
|
|
const navItemClass =
|
|
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors'
|
|
const navIdleClass = 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
const navHoverClass = 'text-foreground bg-accent'
|
|
const navActiveClass = 'bg-primary text-primary-foreground hover:bg-primary/90'
|
|
|
|
/**
|
|
* Wo man gerade steht. „/dashboard" nur genau dort — sonst waere das
|
|
* Dashboard auf jeder Unterseite mit-aktiv, weil alle Pfade damit
|
|
* anfangen.
|
|
*/
|
|
function isActive(href: string) {
|
|
const path = page.url.pathname
|
|
return href === '/dashboard' ? path === '/dashboard' : path.startsWith(href)
|
|
}
|
|
|
|
/**
|
|
* Die vollstaendige Klassenliste eines Menuepunkts. Bewusst eine
|
|
* Funktion und kein zusaetzliches `class:`: `text-foreground` und
|
|
* `text-muted-foreground` sind dieselbe Utility-Gruppe — stuenden beide
|
|
* am Element, entschiede die Reihenfolge im Stylesheet und nicht die im
|
|
* Attribut.
|
|
*/
|
|
function navClass(href: string, hovered = false) {
|
|
if (isActive(href)) return `${navItemClass} ${navActiveClass}`
|
|
return `${navItemClass} ${hovered ? navHoverClass : navIdleClass}`
|
|
}
|
|
|
|
// Logo und Menuepunkt „Dashboard" fuehren an dieselbe Stelle, also
|
|
// reagieren sie gemeinsam — egal, welches von beiden man ueberfaehrt.
|
|
// Ueber CSS ginge das nicht: Der Menuepunkt steckt in der <nav>, das Logo
|
|
// daneben, und peer-hover reicht nur an Geschwister, nicht in sie hinein.
|
|
let dashHover = $state(false)
|
|
|
|
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
|
|
// passiert dort, wo es hingehört — an der Stage innerhalb ihres Events.
|
|
const nav = [
|
|
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
|
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
|
{ href: '/dashboard/team', icon: Users, label: 'Team', submenu: true },
|
|
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
|
|
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
|
|
]
|
|
</script>
|
|
|
|
{#if auth.user}
|
|
<div class="relative min-h-screen bg-background">
|
|
<!--
|
|
Das Logo als Wasserzeichen: fest in der Ecke, gedreht und sehr
|
|
blass. aria-hidden und pointer-events-none, damit es weder
|
|
vorgelesen wird noch Klicks abfängt. Im Dunkelmodus etwas
|
|
kräftiger, sonst verschwindet es im dunklen Grund.
|
|
-->
|
|
<div
|
|
aria-hidden="true"
|
|
class="pointer-events-none fixed -bottom-24 -right-24 z-0 select-none opacity-[0.04] dark:opacity-[0.07]"
|
|
>
|
|
|
|
<Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" />
|
|
</div>
|
|
|
|
<!--
|
|
z-20 und nicht z-10: <main> weiter unten ist ebenfalls
|
|
`relative z-10` und steht spaeter im DOM, liegt also oben. Das
|
|
aufgeklappte Team-Panel haengt unter die Kopfleiste hinein in
|
|
diese Flaeche und lag dadurch dahinter — der Zeiger traf beim
|
|
Hinunterfahren `main` statt das Panel, und das Menue klappte zu,
|
|
bevor jemand einen Eintrag treffen konnte. Ein z-50 am Panel half
|
|
nicht: Die Kopfleiste ist mit ihrem z-index ein eigener
|
|
Stapelkontext, aus dem kein Kind herausragt.
|
|
-->
|
|
<header class="relative z-20 border-b bg-card">
|
|
<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">
|
|
<!-- Links: Logo und Menü. Das negative -my-3 frisst das
|
|
py-2 der Leiste auf und noch etwas mehr: Das Logo
|
|
steht dadurch 72 px hoch in einer 64 px hohen Leiste,
|
|
ragt oben und unten leicht darueber hinaus und wirkt
|
|
praesenter, ohne den Header hoeher zu machen. -->
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<a
|
|
href="/dashboard"
|
|
class="shrink-0 transition-transform relative duration-75 top-2"
|
|
class:scale-120={dashHover}
|
|
class:rotate-3={dashHover}
|
|
onmouseenter={() => (dashHover = true)}
|
|
onmouseleave={() => (dashHover = false)}
|
|
>
|
|
<Logo team={teams.active} class="size-[4.5rem] -my-3" />
|
|
</a>
|
|
|
|
<!--
|
|
Ohne Viewport: Der Inhalt haengt dann am
|
|
Menuepunkt, der als NavigationMenu.Item ohnehin
|
|
`relative` ist. Mit Viewport waere er eine eigene
|
|
Flaeche unter der ganzen Leiste und stuende an
|
|
deren linkem Rand — unter „Dashboard" statt unter
|
|
„Team". -->
|
|
<NavigationMenu.Root viewport={false} class="hidden md:flex">
|
|
<NavigationMenu.List class="items-center gap-2">
|
|
{#each nav as item}
|
|
{@const Icon = item.icon}
|
|
{#if item.submenu}
|
|
<TeamMenu class={navClass(item.href)} />
|
|
{:else}
|
|
<NavigationMenu.Item>
|
|
<NavigationMenu.Link
|
|
href={item.href}
|
|
class={navClass(item.href, dashHover && item.href === '/dashboard')}
|
|
onmouseenter={() => item.href === '/dashboard' && (dashHover = true)}
|
|
onmouseleave={() => item.href === '/dashboard' && (dashHover = false)}
|
|
>
|
|
<Icon class="h-4 w-4" />
|
|
<span>{item.label}</span>
|
|
</NavigationMenu.Link>
|
|
</NavigationMenu.Item>
|
|
{/if}
|
|
{/each}
|
|
</NavigationMenu.List>
|
|
</NavigationMenu.Root>
|
|
</div>
|
|
|
|
<!-- Rechts: wer man ist, und was man mit sich tun kann.
|
|
Das Team steht jetzt im Menue links. -->
|
|
<div class="flex items-center gap-2">
|
|
<div class="hidden md:flex min-w-0 items-center gap-3">
|
|
<a
|
|
href="/dashboard/settings"
|
|
class="flex min-w-0 items-center gap-2"
|
|
use:tooltip={{ content: 'Profil' }}
|
|
>
|
|
<Avatar user={auth.user} size={24} />
|
|
<span class="truncate text-sm font-medium leading-none">
|
|
{auth.user.name || auth.user.email}
|
|
</span>
|
|
</a>
|
|
|
|
<span class="flex shrink-0 items-center gap-1">
|
|
<!--
|
|
Der Zustand der Verbindung steht dauerhaft
|
|
da und nicht erst, wenn etwas schiefgeht:
|
|
Wer gleich eine Stage nimmt, will vorher
|
|
wissen, woran er ist.
|
|
-->
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="size-7"
|
|
onclick={() => connection.check()}
|
|
tooltip={connection.online
|
|
? 'Verbindung steht'
|
|
: 'Keine Verbindung zum Server — hier klicken, um erneut zu versuchen'}
|
|
>
|
|
{#if connection.online}
|
|
<Wifi class="h-4 w-4 text-muted-foreground" />
|
|
{:else}
|
|
<WifiOff class="h-4 w-4 text-amber-600" />
|
|
{/if}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="size-7"
|
|
onclick={toggleMode}
|
|
tooltip="Design wechseln"
|
|
>
|
|
{#if mode.current === 'dark'}
|
|
<Sun class="h-4 w-4" />
|
|
{:else}
|
|
<Moon class="h-4 w-4" />
|
|
{/if}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="size-7"
|
|
onclick={handleLogout}
|
|
tooltip="Abmelden"
|
|
>
|
|
<LogOut class="h-4 w-4" />
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="md:hidden"
|
|
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
|
tooltip="Menü"
|
|
>
|
|
{#if isMobileMenuOpen}
|
|
<X class="h-4 w-4" />
|
|
{:else}
|
|
<Menu class="h-4 w-4" />
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if isMobileMenuOpen}
|
|
<div class="md:hidden border-t">
|
|
<nav class="px-2 py-2 space-y-1">
|
|
{#each nav as item}
|
|
{@const Icon = item.icon}
|
|
<a
|
|
href={item.href}
|
|
onclick={() => (isMobileMenuOpen = false)}
|
|
class="w-full {navClass(item.href)}"
|
|
>
|
|
<Icon class="h-4 w-4" />
|
|
<span>{item.label}</span>
|
|
</a>
|
|
|
|
<!--
|
|
Auf dem Touchscreen gibt es kein Ueberfahren,
|
|
also auch kein Aufklappen. Die Teams stehen
|
|
hier eingerueckt unter ihrem Menuepunkt —
|
|
sichtbar statt versteckt, und der einzige Weg,
|
|
auf dem sich ohne Zeiger wechseln laesst.
|
|
-->
|
|
{#if item.submenu}
|
|
<div class="ml-6 space-y-1 border-l pl-3">
|
|
{#each teams.records as t (t.id)}
|
|
<button
|
|
type="button"
|
|
onclick={() => {
|
|
teams.setActive(t.id)
|
|
isMobileMenuOpen = false
|
|
}}
|
|
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent {t.id ===
|
|
teams.activeId
|
|
? 'font-medium text-foreground'
|
|
: 'text-muted-foreground'}"
|
|
>
|
|
<span class="flex-1 truncate">{t.name}</span>
|
|
{#if t.id === teams.activeId}
|
|
<span class="text-xs text-muted-foreground">aktiv</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
<button
|
|
type="button"
|
|
onclick={() => {
|
|
isMobileMenuOpen = false
|
|
createTeamDialog = true
|
|
}}
|
|
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent"
|
|
>
|
|
<Plus class="h-4 w-4" />
|
|
Team erstellen
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
<Separator class="my-2" />
|
|
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
|
<div class="flex flex-col items-start gap-1 min-w-0">
|
|
<span class="flex items-center gap-2 min-w-0">
|
|
<Avatar user={auth.user} size={24} />
|
|
<span class="text-sm font-medium leading-none truncate">
|
|
{auth.user.name || auth.user.email}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln">
|
|
{#if mode.current === 'dark'}
|
|
<Sun class="h-4 w-4" />
|
|
{:else}
|
|
<Moon class="h-4 w-4" />
|
|
{/if}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onclick={handleLogout} tooltip="Abmelden">
|
|
<LogOut class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
{/if}
|
|
</header>
|
|
|
|
<OfflineBanner />
|
|
|
|
<main class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{@render children?.()}
|
|
</main>
|
|
|
|
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
|
|
<RunningTimesToast />
|
|
|
|
<CreateTeamDialog bind:open={createTeamDialog} />
|
|
</div>
|
|
{/if}
|