Files
flimmer/src/components/UserMenu.tsx
T
Florian Wilde b001e62695 feat: App-Hülle mit Header, Theme-Umschalter und Übersicht
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.
2026-09-01 18:46:47 +02:00

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>
);
}