Layout mit Inline-Skript, das data-theme setzt, bevor der erste Pixel gemalt wird — sonst blitzt beim Laden das falsche Farbschema auf. Logo als React-Komponente statt als Bilddatei: nimmt Theme-Farben an, spart einen Netzwerkabruf und die Verläufe liegen einmalig im Root-Layout. Im hellen Modus greifen eigene Tokens für Verlaufstext und Wortmarke; der vollfarbige Signaturverlauf fällt auf warmem Weiß sonst unter WCAG AA.
116 lines
3.6 KiB
TypeScript
116 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import Link from "next/link";
|
|
import type { SessionUser } from "@/lib/auth";
|
|
|
|
/**
|
|
* Nutzermenü mit Initialen-Avatar.
|
|
* Bewusst ohne Menü-Bibliothek: ein <details>-loses Popover mit Escape- und
|
|
* Außenklick-Behandlung reicht und bleibt tastaturbedienbar.
|
|
*/
|
|
export function UserMenu({ user }: { user: SessionUser }) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onPointer = (event: MouseEvent) => {
|
|
if (!ref.current?.contains(event.target as Node)) setOpen(false);
|
|
};
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onPointer);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onPointer);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [open]);
|
|
|
|
const initials = user.name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
|
.join("");
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((value) => !value)}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
className="fl-gradient-bg grid h-9 w-9 place-items-center rounded-full text-sm font-bold text-[var(--fl-on-accent)] shadow-[var(--fl-glow)] transition-shadow hover:shadow-[var(--fl-glow-strong)]"
|
|
>
|
|
{initials || "?"}
|
|
<span className="sr-only">Menü für {user.name}</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
role="menu"
|
|
className="fl-card absolute right-0 z-50 mt-2 w-60 overflow-hidden p-1.5"
|
|
style={{ backgroundColor: "var(--fl-surface-solid)" }}
|
|
>
|
|
<div className="border-b border-[var(--fl-border)] px-3 py-2">
|
|
<p className="truncate text-sm font-semibold">{user.name}</p>
|
|
<p className="truncate text-xs text-[var(--fl-faint)]">{user.email}</p>
|
|
</div>
|
|
|
|
<MenuLink href="/einstellungen" onClick={() => setOpen(false)}>
|
|
Einstellungen
|
|
</MenuLink>
|
|
<MenuLink href="/einstellungen/benachrichtigungen" onClick={() => setOpen(false)}>
|
|
Benachrichtigungen
|
|
</MenuLink>
|
|
<MenuLink href="/einstellungen/api" onClick={() => setOpen(false)}>
|
|
API-Schlüssel
|
|
</MenuLink>
|
|
<MenuLink href="/papierkorb" onClick={() => setOpen(false)}>
|
|
Papierkorb
|
|
</MenuLink>
|
|
{user.role === "ADMIN" && (
|
|
<MenuLink href="/verwaltung" onClick={() => setOpen(false)}>
|
|
Verwaltung
|
|
</MenuLink>
|
|
)}
|
|
|
|
<form action="/api/auth/abmelden" method="post" className="border-t border-[var(--fl-border)] pt-1.5">
|
|
<button
|
|
type="submit"
|
|
role="menuitem"
|
|
className="w-full rounded-lg px-3 py-2 text-left text-sm text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
|
|
>
|
|
Abmelden
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MenuLink({
|
|
href,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
href: string;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Link
|
|
href={href}
|
|
role="menuitem"
|
|
onClick={onClick}
|
|
className="block rounded-lg px-3 py-2 text-sm text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
|
|
>
|
|
{children}
|
|
</Link>
|
|
);
|
|
}
|