Die Schnellerfassung braucht ein Feld und einen Knopf; wer beim Erfassen erst ein Formular ausfüllen muss, erfasst nichts. Der Fokus bleibt danach im Feld, damit mehrere Sachen hintereinander hineingehen. Die Filter der Aufgabenliste stehen in der Adresse, nicht im Zustand der Komponente — so lässt sich eine gefilterte Liste als Lesezeichen ablegen, und der Zurück-Knopf tut, was er soll. Die Startseite zeigt jetzt, was überfällig, heute oder morgen fällig ist, statt nur die Bereiche aufzuzählen. Für die Auswahlfelder bewusst das native <select>: es bedient sich auf dem Handy mit dem Systemdialog und ist ohne Zusatzcode tastaturbedienbar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
'use client'
|
||
|
||
import Link from 'next/link'
|
||
import { usePathname } from 'next/navigation'
|
||
import { useTheme } from 'next-themes'
|
||
import { LogOut, Moon, Shield, Sun } from 'lucide-react'
|
||
import { signOut } from 'next-auth/react'
|
||
import type { Rolle } from '@/generated/prisma/enums'
|
||
import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
|
||
import { cn } from '@/lib/utils'
|
||
import { Button } from '@/components/ui/button'
|
||
|
||
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number }
|
||
|
||
export function Kopfleiste({
|
||
name,
|
||
email,
|
||
rolle,
|
||
instanzName,
|
||
inbox = 0,
|
||
}: {
|
||
name: string
|
||
email: string
|
||
rolle: Rolle
|
||
instanzName: string
|
||
/** Anzahl noch nicht einsortierter Aufgaben. */
|
||
inbox?: number
|
||
}) {
|
||
const EINTRAEGE: Eintrag[] = [
|
||
{ pfad: '/', name: 'Start' },
|
||
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
|
||
{ pfad: '/aufgaben', name: 'Aufgaben' },
|
||
{ pfad: '/projekte', name: 'Projekte' },
|
||
{ pfad: '/ideen', name: 'Ideen' },
|
||
{ pfad: '/bereiche', name: 'Bereiche' },
|
||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||
]
|
||
|
||
const pfad = usePathname()
|
||
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
|
||
|
||
return (
|
||
<header className="border-rand bg-karte border-b">
|
||
{/*
|
||
Bewusst eine einzige Zeile auf allen Breiten: Wird es eng, scrollt die
|
||
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
|
||
die zweite Zeile zu drängen.
|
||
*/}
|
||
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:gap-6">
|
||
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
|
||
Planer
|
||
</Link>
|
||
|
||
<nav
|
||
aria-label="Hauptnavigation"
|
||
className="flex min-w-0 flex-1 gap-1 overflow-x-auto"
|
||
>
|
||
{sichtbar.map((eintrag) => {
|
||
const aktiv =
|
||
eintrag.pfad === '/' ? pfad === '/' : pfad.startsWith(eintrag.pfad)
|
||
return (
|
||
<Link
|
||
key={eintrag.pfad}
|
||
href={eintrag.pfad}
|
||
aria-current={aktiv ? 'page' : undefined}
|
||
className={cn(
|
||
'shrink-0 rounded-md px-3 py-1.5 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}
|
||
{eintrag.zaehler !== undefined && eintrag.zaehler > 0 && (
|
||
<span
|
||
className="bg-betont text-betont-vordergrund ml-1.5 inline-flex min-w-4 justify-center rounded-full px-1 text-[0.6875rem] leading-4 font-medium"
|
||
aria-label={`${eintrag.zaehler} offen`}
|
||
>
|
||
{eintrag.zaehler}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<FarbschemaKnopf />
|
||
<span
|
||
className="text-gedaempft-vordergrund hidden text-xs sm:inline"
|
||
title={`${email} · ${ROLLEN_NAME[rolle]} · ${instanzName}`}
|
||
>
|
||
{name}
|
||
</span>
|
||
<Button
|
||
art="leise"
|
||
groesse="quadrat"
|
||
title="Abmelden"
|
||
onClick={() => void signOut({ callbackUrl: '/anmelden' })}
|
||
>
|
||
<LogOut aria-hidden />
|
||
<span className="sr-only">Abmelden</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
)
|
||
}
|
||
|
||
function FarbschemaKnopf() {
|
||
const { resolvedTheme, setTheme } = useTheme()
|
||
|
||
// Welches Symbol gilt, entscheidet die dark-Klasse und damit CSS. next-themes
|
||
// setzt sie schon vor der Hydratation, deshalb braucht es hier weder einen
|
||
// Effekt noch einen Zustand – und es blitzt nichts auf.
|
||
return (
|
||
<Button
|
||
art="leise"
|
||
groesse="quadrat"
|
||
title="Farbschema wechseln"
|
||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||
>
|
||
<Moon className="dark:hidden" aria-hidden />
|
||
<Sun className="hidden dark:block" aria-hidden />
|
||
<span className="sr-only">Farbschema wechseln</span>
|
||
</Button>
|
||
)
|
||
}
|