fix: Navigation aufteilen, statt sie weiter zu dehnen

Mit dem dreizehnten Eintrag verschluckte die Leiste erneut Punkte am
rechten Rand — nach M6 zum zweiten Mal. Das Problem ist nicht die Breite,
sondern die Vermischung: Einrichtung und Verwaltung sucht man nicht beim
Arbeiten. Alltag bleibt in der Leiste, alles andere liegt hinter "Mehr".

Ein <details> statt eines Menues aus Skript: Es klappt ohne JavaScript
auf und braucht keine Zustandsverwaltung.

Zurueckgezogene Tokens stehen jetzt unten. Was noch Zugang hat, ist das,
wonach man sucht.

Zwei E2E-Tests greifen den neuen Weg ueber "Mehr" auf. Ein dritter wartet
das Neuladen ab, bevor er navigiert — die Meldung kommt frueher als die
fertige Liste, und Chromium bricht die Navigation sonst ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-12 18:52:35 +02:00
co-authored by Claude Opus 5
parent 2e57713a8e
commit 7919149879
4 changed files with 78 additions and 11 deletions
+7 -1
View File
@@ -50,7 +50,11 @@ test.describe('Anmeldung', () => {
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
// exact, weil die Startseite zusätzlich einen Link "Bereiche verwalten" hat. // Alltagspunkte stehen in der Leiste, Einrichtung liegt hinter "Mehr".
await expect(page.getByRole('link', { name: 'Aufgaben', exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeHidden()
await page.getByText('Mehr', { exact: true }).click()
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible() await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible()
await page.getByTitle('Abmelden').click() await page.getByTitle('Abmelden').click()
@@ -64,6 +68,8 @@ test.describe('Anmeldung', () => {
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
// Das Protokoll liegt hinter "Mehr" – aufklappen, dann hin.
await page.getByText('Mehr', { exact: true }).click()
await page.getByRole('link', { name: 'Protokoll' }).click() await page.getByRole('link', { name: 'Protokoll' }).click()
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
}) })
+4
View File
@@ -52,6 +52,10 @@ test.describe('Aufgaben', () => {
await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id) await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id)
await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible() await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible()
// Die Meldung kommt, bevor das Neuladen der Liste fertig ist. Wer jetzt
// navigiert, bricht es ab — und Chromium meldet ERR_ABORTED.
await page.waitForLoadState('networkidle')
// Aus der Inbox ist sie damit verschwunden. // Aus der Inbox ist sie damit verschwunden.
await page.goto('/inbox') await page.goto('/inbox')
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0) await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
+3 -1
View File
@@ -20,7 +20,9 @@ export default async function ApiSeite() {
await verlangeRolle('ADMIN', '/verwaltung/api') await verlangeRolle('ADMIN', '/verwaltung/api')
const tokens = await db.apiToken.findMany({ const tokens = await db.apiToken.findMany({
orderBy: [{ widerrufenAm: 'asc' }, { erstelltAm: 'desc' }], // Gueltige zuerst, Zurueckgezogenes ans Ende: Was noch Zugang hat, ist
// das, wonach man sucht.
orderBy: [{ widerrufenAm: { sort: 'asc', nulls: 'first' } }, { erstelltAm: 'desc' }],
select: { select: {
id: true, id: true,
name: true, name: true,
+64 -9
View File
@@ -3,7 +3,7 @@
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { LogOut, Moon, Shield, Sun } from 'lucide-react' import { ChevronDown, LogOut, Moon, Shield, Sun } from 'lucide-react'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import type { Rolle } from '@/generated/prisma/enums' import type { Rolle } from '@/generated/prisma/enums'
import { ROLLEN_NAME, istAdmin } from '@/lib/rollen' import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
@@ -12,6 +12,16 @@ import { Button } from '@/components/ui/button'
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number } type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number }
/**
* Die Leiste traegt nur, was taeglich gebraucht wird.
*
* Mit jedem Meilenstein kam ein Punkt dazu, und zweimal hat die Leiste
* daraufhin still Eintraege am rechten Rand verschluckt. Das Problem ist
* nicht die Breite, sondern die Vermischung: Einrichtung und Verwaltung
* sucht man nicht beim Arbeiten, sondern wenn man sie braucht. Sie liegen
* deshalb hinter "Mehr".
*/
export function Kopfleiste({ export function Kopfleiste({
name, name,
email, email,
@@ -34,16 +44,21 @@ export function Kopfleiste({
{ pfad: '/woche', name: 'Woche' }, { pfad: '/woche', name: 'Woche' },
{ pfad: '/projekte', name: 'Projekte' }, { pfad: '/projekte', name: 'Projekte' },
{ pfad: '/ideen', name: 'Ideen' }, { pfad: '/ideen', name: 'Ideen' },
]
const WEITERE: Eintrag[] = [
{ pfad: '/bereiche', name: 'Bereiche' }, { pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/kalenderkonten', name: 'Kalender' }, { pfad: '/kalenderkonten', name: 'Kalender' },
{ pfad: '/integrationen', name: 'Integrationen' }, { pfad: '/integrationen', name: 'Integrationen' },
{ pfad: '/einstellungen', name: 'Einstellungen' }, { pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/api', name: 'API', nurAdmin: true }, { pfad: '/verwaltung/api', name: 'API-Tokens', nurAdmin: true },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true }, { pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
] ]
const pfad = usePathname() const pfad = usePathname()
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle)) const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
const weitere = WEITERE.filter((e) => !e.nurAdmin || istAdmin(rolle))
const imMenue = weitere.some((e) => pfad.startsWith(e.pfad))
return ( return (
<header className="border-rand bg-karte border-b"> <header className="border-rand bg-karte border-b">
@@ -52,13 +67,7 @@ export function Kopfleiste({
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
die zweite Zeile zu drängen. die zweite Zeile zu drängen.
*/} */}
{/* <div className="mx-auto flex w-full max-w-6xl items-center gap-3 px-4 py-3 sm:gap-5">
Breiter als die Inhaltsspalte (max-w-5xl): Mit dem elften Eintrag
passte die Leiste dort nicht mehr, und "Einstellungen" verschwand
stillschweigend am rechten Rand. Eine Leiste, die Punkte verschluckt,
ist schlimmer als eine, die nicht buendig mit dem Text steht.
*/}
<div className="mx-auto flex w-full max-w-7xl items-center gap-3 px-4 py-3 sm:gap-5">
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight"> <Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
Planer Planer
</Link> </Link>
@@ -97,6 +106,52 @@ export function Kopfleiste({
</Link> </Link>
) )
})} })}
{/*
Ein <details> statt eines Menues aus Skript: Es klappt ohne
JavaScript auf, schliesst sich beim Klick daneben und braucht
keine Zustandsverwaltung.
*/}
<details className="group relative shrink-0" name="kopfleiste">
<summary
className={cn(
'flex cursor-pointer list-none items-center gap-1 rounded-md px-3 py-1.5 text-sm whitespace-nowrap transition-colors',
imMenue
? 'bg-gedaempft text-vordergrund font-medium'
: 'text-gedaempft-vordergrund hover:bg-gedaempft hover:text-vordergrund',
)}
>
Mehr
<ChevronDown
className="size-3.5 transition-transform group-open:rotate-180"
aria-hidden
/>
</summary>
<div className="border-rand bg-karte absolute right-0 z-20 mt-1 flex w-56 flex-col rounded-md border p-1 shadow-md">
{weitere.map((eintrag) => {
const aktiv = pfad.startsWith(eintrag.pfad)
return (
<Link
key={eintrag.pfad}
href={eintrag.pfad}
aria-current={aktiv ? 'page' : undefined}
className={cn(
'rounded-md px-3 py-2 text-sm whitespace-nowrap transition-colors',
aktiv
? 'bg-gedaempft text-vordergrund font-medium'
: 'text-gedaempft-vordergrund hover:bg-gedaempft hover:text-vordergrund',
)}
>
{eintrag.nurAdmin && (
<Shield className="mr-1 inline size-3.5" aria-hidden />
)}
{eintrag.name}
</Link>
)
})}
</div>
</details>
</nav> </nav>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">