Prueft jeden Push und jeden Pull Request: Typen, Unit-Tests, Bau und Ende-zu-Ende-Tests gegen einen frischen PostgreSQL-Dienst. Schlaegt etwas fehl, wird der Playwright-Bericht als Artefakt abgelegt. Der Datenbankname des Dienstes enthaelt "test", sonst verweigert global-setup.ts den Dienst. In CI wird das mitgelieferte Chromium benutzt statt des oertlich installierten Chrome. Die Syntax ist GitHub-kompatibel - fuer GitHub genuegt es, die Datei nach .github/workflows zu kopieren. Nebenbei bekommt der Knopf des Benutzermenues eine Beschriftung. Ohne sie las ein Screenreader auf schmalen Anzeigen nur das zweibuchstabige Kuerzel vor, und im Test war er nicht eindeutig vom Datenmenue zu unterscheiden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { usePathname, useRouter } from "next/navigation";
|
||
import {
|
||
GitBranch,
|
||
LayoutDashboard,
|
||
LogOut,
|
||
Settings,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
UserRound,
|
||
} from "lucide-react";
|
||
import { Popover } from "@/components/ui/Popover";
|
||
import type { SessionUser } from "@/lib/auth/session";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
const LINKS = [
|
||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
|
||
];
|
||
|
||
export function TopNav({
|
||
user,
|
||
appName,
|
||
tagline,
|
||
}: {
|
||
user: SessionUser | null;
|
||
appName: string;
|
||
tagline: string;
|
||
}) {
|
||
const pathname = usePathname();
|
||
|
||
return (
|
||
<header className="sticky top-0 z-40 border-b border-white/5 bg-[#05060f]/70 backdrop-blur-xl">
|
||
<div className="mx-auto flex w-full max-w-[1400px] items-center gap-6 px-4 py-3 sm:px-6 lg:px-8">
|
||
<Link href="/" className="group flex items-center gap-3">
|
||
<span className="relative grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400 shadow-[0_8px_28px_-10px_rgba(168,85,247,0.9)] transition-transform group-hover:scale-105">
|
||
<Sparkles className="h-4 w-4 text-white" strokeWidth={2.4} />
|
||
</span>
|
||
<span className="flex flex-col leading-none">
|
||
<span className="text-gradient text-lg font-semibold tracking-tight">
|
||
{appName}
|
||
</span>
|
||
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
||
{tagline}
|
||
</span>
|
||
</span>
|
||
</Link>
|
||
|
||
{user ? (
|
||
<>
|
||
<nav className="flex items-center gap-1">
|
||
{LINKS.map(({ href, label, icon: Icon }) => {
|
||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||
return (
|
||
<Link
|
||
key={href}
|
||
href={href}
|
||
className={cn(
|
||
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
|
||
active
|
||
? "bg-white/8 text-white"
|
||
: "text-slate-400 hover:bg-white/5 hover:text-slate-100",
|
||
)}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
<span className="hidden sm:inline">{label}</span>
|
||
</Link>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
<div className="ml-auto">
|
||
<UserMenu user={user} />
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function UserMenu({ user }: { user: SessionUser }) {
|
||
const router = useRouter();
|
||
const [open, setOpen] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||
|
||
async function logout() {
|
||
setBusy(true);
|
||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => undefined);
|
||
setOpen(false);
|
||
router.replace("/login");
|
||
router.refresh();
|
||
}
|
||
|
||
const label = user.displayName?.trim() || user.username;
|
||
const initials = label.slice(0, 2).toUpperCase();
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
onClick={() => setOpen((value) => !value)}
|
||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
||
aria-haspopup="menu"
|
||
aria-expanded={open}
|
||
// Auf schmalen Anzeigen bleibt nur das Kuerzel sichtbar – ohne
|
||
// Beschriftung liest ein Screenreader dann nur zwei Buchstaben vor.
|
||
aria-label={`Benutzermenue fuer ${label}`}
|
||
>
|
||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500/80 to-cyan-400/70 text-[11px] font-semibold text-white">
|
||
{initials}
|
||
</span>
|
||
<span className="hidden max-w-[10rem] truncate sm:inline">{label}</span>
|
||
{user.role === "ADMIN" ? (
|
||
<ShieldCheck className="hidden h-3.5 w-3.5 text-violet-300 sm:inline" />
|
||
) : null}
|
||
</button>
|
||
|
||
<Popover anchorRef={triggerRef} open={open} onClose={() => setOpen(false)} width={240}>
|
||
<div className="border-b border-white/6 px-3 py-2">
|
||
<p className="truncate text-sm text-slate-100">{label}</p>
|
||
<p className="truncate text-[11px] text-slate-500">
|
||
@{user.username}
|
||
{user.role === "ADMIN" ? " · Administrator" : ""}
|
||
</p>
|
||
</div>
|
||
|
||
<Link
|
||
href="/account"
|
||
role="menuitem"
|
||
onClick={() => setOpen(false)}
|
||
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||
>
|
||
<UserRound className="h-4 w-4" />
|
||
Mein Konto
|
||
</Link>
|
||
|
||
{user.role === "ADMIN" ? (
|
||
<Link
|
||
href="/admin"
|
||
role="menuitem"
|
||
onClick={() => setOpen(false)}
|
||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||
>
|
||
<Settings className="h-4 w-4" />
|
||
Admin-Einstellungen
|
||
</Link>
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={logout}
|
||
disabled={busy}
|
||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
|
||
>
|
||
<LogOut className="h-4 w-4" />
|
||
Abmelden
|
||
</button>
|
||
</Popover>
|
||
</>
|
||
);
|
||
}
|