feat: Team wird zum Menuepunkt mit Submenue

Das Teamdropdown stand als eigenes Bedienelement neben dem Namen — ein
zweiter Schalter fuer eine Sache, die schon einen Menuepunkt hat. Jetzt
klappt der Menuepunkt Team beim Ueberfahren auf: die eigenen Teams, ein
Klick wechselt und oeffnet die Teamseite, darunter "Team erstellen".

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
Daniel Michelberger 2026-09-07 22:11:28 +02:00
parent 45835cd45c
commit 66e351e54f
4 changed files with 313 additions and 98 deletions

View file

@ -0,0 +1,76 @@
<script lang="ts">
/**
* Dialog „Neues Team". Liegt als eigene Komponente vor, weil es zwei
* Einstiege gibt — den Teamwechsler im Kopf und die Teamseite — und ein
* neues Team an beiden Stellen dasselbe bedeuten muss: Team anlegen, den
* Standardsatz Flag-Typen dazu, und ab dann ist es das aktive Team.
*
* Ein Team ohne Flag-Typen kann zwar Marker aufnehmen, aber nichts
* einordnen. Deshalb kommt der Standardsatz gleich mit — änderbar und,
* wie alle Flag-Typen, allein Sache dieses Teams.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { Button } from '@/components/ui/button'
import * as Dialog from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
let { open = $bindable(false) }: { open?: boolean } = $props()
const teams = getTeamContext()
const flags = getTrailFlagContext()
let name = $state('')
let error = $state<string | null>(null)
let busy = $state(false)
async function submit() {
if (!name.trim() || busy) return
busy = true
error = null
try {
const created = await teams.create(name.trim())
try {
await flags.seedDefaults(created.id)
} catch (e: any) {
error =
'Das Team wurde angelegt, die Standard-Flag-Typen aber nicht: ' +
(e.message ?? 'unbekannter Fehler') +
' Du kannst sie unter „Flag-Typen" nachholen.'
return
}
open = false
name = ''
} catch (e: any) {
error = e.message ?? 'Das Team konnte nicht angelegt werden.'
} finally {
busy = false
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neues Team</Dialog.Title>
<Dialog.Description>Du wirst automatisch Owner.</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-name">Name</Label>
<Input id="team-name" bind:value={name} placeholder="Mein Team" onkeydown={(e) => e.key === 'Enter' && submit()} />
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
<Button onclick={submit} disabled={!name.trim() || busy}>Erstellen</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -18,11 +18,11 @@
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
</script>
<div class="relative {className}" id="logo">
<div class="relative {className} hover:scale-110 hover:rotate-3 transition-transform" id="logo">
{#if url}
<img alt="" class="absolute inset-0 h-full w-full object-contain" src={url} />
<img alt="" class="absolute h-full w-full object-contain" src={url} />
{:else}
<img alt="" class="absolute inset-0 h-full w-full object-contain" src="/schildl.svg">
<img alt="" class="absolute inset-0 h-full w-full scale-110 object-contain" src="/radl.svg">
<img alt="" class="absolute h-full w-full object-contain" src="/schildl.svg">
<img alt="" class="absolute h-full w-full scale-110 object-contain" src="/radl.svg">
{/if}
</div>

View file

@ -1,29 +1,110 @@
<script lang="ts">
/**
* Der Menuepunkt „Team" in der Kopfleiste — ein Menuepunkt, der sich beim
* Ueberfahren aufklappt: darunter der Wechsel zwischen den eigenen Teams
* und der Weg zu einem neuen.
*
* Der Punkt selbst ist ein Link auf die Teamseite. Ein eigener Eintrag
* „Uebersicht" waere die zweite Tuer zu derselben Seite, und eine
* Ueberschrift ueber drei Teamnamen sagt nichts, was die Namen nicht
* schon sagen.
*
* Auf dem Touchscreen gibt es kein Ueberfahren: Dort fuehrt der Tipp auf
* die Teamseite, und gewechselt wird in deren Liste „Meine Teams".
*/
import { goto } from '$app/navigation'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { Button } from '@/components/ui/button'
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
import * as DropdownMenu from '@/components/ui/dropdown-menu'
import { ChevronDown, Plus, Users } from 'lucide-svelte'
import { goto } from '$app/navigation'
let {
class: className = '',
onnavigate,
}: { class?: string; onnavigate?: () => void } = $props()
const teams = getTeamContext()
let createDialog = $state(false)
let menuOpen = $state(false)
// Zwischen Knopf und aufgeklapptem Menue liegt ein Spalt. Ohne die kurze
// Frist fiele das Menue zu, sobald der Zeiger ihn ueberquert.
let closeTimer: ReturnType<typeof setTimeout> | null = null
function enter() {
if (closeTimer) clearTimeout(closeTimer)
menuOpen = true
}
function leave() {
if (closeTimer) clearTimeout(closeTimer)
closeTimer = setTimeout(() => (menuOpen = false), 150)
}
/**
* Team wechseln heisst hier auch: hinsehen. Der Wechsel allein aendert
* bloss still, was jede Liste zeigt — die Teamseite macht sichtbar,
* worauf man gewechselt hat.
*/
function switchTo(id: string) {
teams.setActive(id)
goto('/dashboard/team')
onnavigate?.()
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Root bind:open={menuOpen}>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="gap-2">
<!--
Die Zeiger- und Tastenhandler von bits-ui werden bewusst
ueberschrieben: Deren onclick ruft preventDefault und macht
den Link damit tot, und ihr pointerdown klappt das Menue zu,
das der Hover gerade aufgeklappt hat — zwei Herren ueber
dasselbe `open`, das Ergebnis war Flackern statt Navigation.
Hier klappt allein der Hover auf, und der Klick folgt dem
Link wie bei jedem anderen Menuepunkt.
-->
<a
{...props}
href="/dashboard/team"
class={className}
onmouseenter={enter}
onmouseleave={leave}
onpointerdown={undefined}
onpointerup={undefined}
onkeydown={undefined}
onclick={() => {
menuOpen = false
onnavigate?.()
}}
>
<Users class="h-4 w-4" />
<span class="font-medium">{teams.active?.name ?? 'Kein Team'}</span>
<span>Team</span>
<ChevronDown class="h-3 w-3 opacity-50" />
</Button>
</a>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="min-w-56">
<DropdownMenu.Label>Teams wechseln</DropdownMenu.Label>
{#each teams.records as t}
{@const isMe = teams.isOwner(t) || teams.isAdmin(t)}
<!--
Aufgeklappt wird beim Ueberfahren, nicht auf Ansage: Das Menue darf
deshalb weder den Fokus an sich ziehen noch das Scrollen sperren —
beides gehoert zu einem Dialog, nicht zu einem Menuepunkt, ueber den
der Zeiger nur streift.
-->
<DropdownMenu.Content
align="start"
class="min-w-56"
trapFocus={false}
preventScroll={false}
onOpenAutoFocus={(e) => e.preventDefault()}
onmouseenter={enter}
onmouseleave={leave}
>
{#each teams.records as t (t.id)}
<DropdownMenu.Item
onclick={() => teams.setActive(t.id)}
onclick={() => switchTo(t.id)}
class={t.id === teams.activeId ? 'bg-accent' : ''}
>
<span class="flex-1">{t.name}</span>
@ -33,11 +114,18 @@
<span class="text-xs text-muted-foreground">Admin</span>
{/if}
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item disabled>Noch in keinem Team</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => goto('/dashboard/settings/team')}>
<Users class="h-4 w-4 mr-2" />
Teams verwalten
<DropdownMenu.Item onclick={() => (createDialog = true)}>
<Plus class="h-4 w-4 mr-2" />
Team erstellen
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Ausserhalb des Menues: mit dem Klick schliesst sich das Dropdown, und ein
darin gerendeter Dialog verschwaende mit ihm. -->
<CreateTeamDialog bind:open={createDialog} />

View file

@ -1,8 +1,10 @@
<script lang="ts">
import { auth } from '$lib/stores/pocketbase.svelte'
import { goto } from '$app/navigation'
import { app, tooltip } from '$lib/stores/app.svelte'
import { page } from '$app/state'
import { tooltip } from '$lib/stores/app.svelte'
import { setTeamContext } from '$lib/stores/teams.svelte'
import { setTeamInviteContext } from '$lib/stores/teamInvites.svelte'
import { setEventContext } from '$lib/stores/events.svelte'
import { setRunContext } from '$lib/stores/runs.svelte'
import { setRiderContext } from '$lib/stores/riders.svelte'
@ -17,10 +19,10 @@
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import Logo from '$lib/components/Logo.svelte'
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
import TeamMenu from '$lib/components/TeamMenu.svelte'
import Avatar from '$lib/components/Avatar.svelte'
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte'
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Users } from 'lucide-svelte'
import { toggleMode, mode } from 'mode-watcher'
import { onDestroy, onMount } from 'svelte'
@ -29,6 +31,7 @@
// 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 runs = setRunContext()
const riders = setRiderContext()
@ -54,9 +57,11 @@
teams.subscribe()
await Promise.all([
events.load(), runs.load(), riders.load(), times.load(), participants.load(), series.load(),
invites.load(),
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
trailComments.load(),
])
invites.subscribe()
events.subscribe()
runs.subscribe()
riders.subscribe()
@ -72,6 +77,7 @@
onDestroy(() => {
teams.destroy()
invites.destroy()
events.destroy()
runs.destroy()
riders.destroy()
@ -85,25 +91,54 @@
trailComments.destroy()
})
function handleLogout() {
app.confirm.request({
title: 'Abmelden?',
text: 'Möchten Sie sich wirklich abmelden?',
yes: async () => {
// 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()
app.confirm.close()
goto('/login')
},
no: () => app.confirm.close(),
})
}
// Ein Menuepunkt sieht aus wie der andere, ob Link oder aufklappbar.
const navItemClass =
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors'
const navIdleClass = 'text-muted-foreground hover:text-foreground hover:bg-accent'
const navHoverClass = 'text-foreground bg-accent'
const navActiveClass = 'bg-primary text-primary-foreground hover:bg-primary/90'
/**
* Wo man gerade steht. „/dashboard" nur genau dort — sonst waere das
* Dashboard auf jeder Unterseite mit-aktiv, weil alle Pfade damit
* anfangen.
*/
function isActive(href: string) {
const path = page.url.pathname
return href === '/dashboard' ? path === '/dashboard' : path.startsWith(href)
}
/**
* Die vollstaendige Klassenliste eines Menuepunkts. Bewusst eine
* Funktion und kein zusaetzliches `class:`: `text-foreground` und
* `text-muted-foreground` sind dieselbe Utility-Gruppe — stuenden beide
* am Element, entschiede die Reihenfolge im Stylesheet und nicht die im
* Attribut.
*/
function navClass(href: string, hovered = false) {
if (isActive(href)) return `${navItemClass} ${navActiveClass}`
return `${navItemClass} ${hovered ? navHoverClass : navIdleClass}`
}
// Logo und Menuepunkt „Dashboard" fuehren an dieselbe Stelle, also
// reagieren sie gemeinsam — egal, welches von beiden man ueberfaehrt.
// Ueber CSS ginge das nicht: Der Menuepunkt steckt in der <nav>, das Logo
// daneben, und peer-hover reicht nur an Geschwister, nicht in sie hinein.
let dashHover = $state(false)
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
// passiert dort, wo es hingehört — am Run innerhalb seines Events.
const nav = [
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
{ href: '/dashboard/riders', icon: Bike, label: 'Fahrer' },
{ href: '/dashboard/team', icon: Users, label: 'Team', submenu: true },
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
]
@ -121,38 +156,53 @@
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>
<header class="relative z-10 border-b bg-card">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
voller Groesse wuerde es die 64 px hohe Leiste
ueberragen und beschnitten wirken. -->
<!-- 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">
<Logo team={teams.active} class="h-11 w-11 shrink-0" />
<a
href="/dashboard"
class="shrink-0 transition-transform relative duration-200 top-2"
class:scale-105={dashHover}
onmouseenter={() => (dashHover = true)}
onmouseleave={() => (dashHover = false)}
>
<Logo team={teams.active} class="size-[4.5rem] -my-3" />
</a>
<nav class="hidden md:flex items-center gap-1">
<nav class="hidden md:flex items-center gap-2">
{#each nav as item}
{@const Icon = item.icon}
{#if item.submenu}
<TeamMenu class={navClass(item.href)} />
{:else}
<a
href={item.href}
class="inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
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>
</a>
{/if}
{/each}
</nav>
</div>
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
Zeile, das Team-Dropdown darunter. -->
<!-- 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">
<!-- Wer man ist, steht links; was man tun kann, rechts. -->
<div class="hidden md:flex min-w-56 flex-col gap-1">
<div class="flex items-center justify-between gap-3">
<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"
@ -190,11 +240,6 @@
</span>
</div>
<div class="flex">
<TeamSwitcher />
</div>
</div>
<Button
variant="ghost"
size="icon"
@ -217,14 +262,21 @@
<nav class="px-2 py-2 space-y-1">
{#each nav as item}
{@const Icon = item.icon}
{#if item.submenu}
<TeamMenu
class="w-full {navClass(item.href)}"
onnavigate={() => (isMobileMenuOpen = false)}
/>
{:else}
<a
href={item.href}
onclick={() => (isMobileMenuOpen = false)}
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent"
class="w-full {navClass(item.href)}"
>
<Icon class="h-4 w-4" />
<span>{item.label}</span>
</a>
{/if}
{/each}
<Separator class="my-2" />
<div class="flex items-center justify-between gap-2 px-3 py-2">
@ -235,7 +287,6 @@
{auth.user.name || auth.user.email}
</span>
</span>
<TeamSwitcher />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln">