Links die einzuplanenden Aufgaben, rechts sieben Tage im Viertelstundenraster. Eine Aufgabe ins Raster ziehen legt einen Zeitblock an und schreibt ihn in den Kalender; ein Block lässt sich am Griff verschieben, das Kreuz plant ihn wieder aus. Die Dauer eines neuen Blocks kommt aus dem geschätzten Aufwand der Aufgabe — wer schätzt, bekommt die passende Größe geschenkt; ohne Schätzung sind es 60 Minuten. Fremde Termine liegen grau im Hintergrund und lassen sich hier nicht anfassen. Sie gehören dem Kalender, nicht dem Planer. Zwei Details: - Ziehen beginnt erst nach acht Pixeln Bewegung. Sonst löst jeder Klick einen Zug aus und die Knöpfe im Block wären nicht mehr bedienbar. - Ein Zeitblock steht auch im Terminspiegel; er würde sonst doppelt erscheinen, einmal als eigene Planung und einmal als fremder Termin. Die Abfrage filtert ihn heraus. Der Wochenversatz steht in der Adresse, damit eine bestimmte Woche als Lesezeichen taugt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
4.5 KiB
TypeScript
135 lines
4.5 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: '/woche', name: 'Woche' },
|
||
{ 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>
|
||
)
|
||
}
|