Files
planer/src/components/kopfleiste.tsx
T
Florian WildeandClaude Opus 5 0c67b73a62 feat(kalender): Seite für die Kalenderkonten
Verbinden, umbenennen, von Hand abgleichen und trennen. Alle Aktionen
arbeiten nur auf Konten des angemeldeten Benutzers — die Kennung geht in
die where-Bedingung, nicht in einen nachträglichen Vergleich.

Ein Anbieter erscheint nur, wenn die Instanz ihn erlaubt *und* er
konfiguriert ist. Ist er erlaubt, aber nicht konfiguriert, sagt die Seite
das ausdrücklich, statt den Knopf wortlos wegzulassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:05:07 +02:00

134 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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: '/kalenderkonten', name: 'Kalender' },
{ 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>
)
}