Files
vereinskalender/components/NavBar.tsx
2026-01-17 20:59:26 +01:00

302 lines
9.7 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { signIn, signOut, useSession } from "next-auth/react";
export default function NavBar() {
const { data } = useSession();
const pathname = usePathname();
const isAdmin = data?.user?.role === "ADMIN" || data?.user?.role === "SUPERADMIN";
const isSuperAdmin = data?.user?.role === "SUPERADMIN";
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [logoBrightness, setLogoBrightness] = useState<number | null>(null);
const [isDarkTheme, setIsDarkTheme] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [appName, setAppName] = useState("Vereinskalender");
const hideLoginPaths = new Set(["/login", "/register", "/reset"]);
const showLoginButton = !data?.user && !hideLoginPaths.has(pathname);
const linkClass = (href: string) =>
pathname === href
? "nav-link-active rounded-full px-3 py-1"
: "nav-link rounded-full px-3 py-1";
useEffect(() => {
const loadLogo = async () => {
try {
const response = await fetch("/api/branding/logo", {
method: "HEAD",
cache: "no-store"
});
if (response.ok) {
setLogoUrl(`/api/branding/logo?ts=${Date.now()}`);
} else {
setLogoUrl(null);
}
} catch {
setLogoUrl(null);
}
};
loadLogo();
}, []);
useEffect(() => {
const loadAppName = async () => {
try {
const response = await fetch("/api/settings/app-name");
if (!response.ok) return;
const payload = await response.json();
setAppName(payload.name || "Vereinskalender");
} catch {
// ignore
}
};
loadAppName();
}, []);
useEffect(() => {
if (typeof document === "undefined") return;
const nextTitle = appName?.trim();
if (nextTitle) {
document.title = nextTitle;
}
}, [appName]);
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.dataset.theme === "dark");
};
updateTheme();
const observer = new MutationObserver(updateTheme);
observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
return () => observer.disconnect();
}, []);
useEffect(() => {
const onScroll = () => {
setIsScrolled(window.scrollY > 12);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
useEffect(() => {
if (!logoUrl) return;
let cancelled = false;
const img = new Image();
img.crossOrigin = "anonymous";
img.src = logoUrl;
img.onload = () => {
if (cancelled) return;
const canvas = document.createElement("canvas");
const size = 32;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.drawImage(img, 0, 0, size, size);
const data = ctx.getImageData(0, 0, size, size).data;
let total = 0;
let count = 0;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha === 0) continue;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
total += 0.2126 * r + 0.7152 * g + 0.0722 * b;
count += 1;
}
if (count > 0) {
setLogoBrightness(total / count);
}
};
return () => {
cancelled = true;
};
}, [logoUrl]);
const shouldInvertLogo =
logoBrightness !== null &&
((isDarkTheme && logoBrightness > 140) ||
(!isDarkTheme && logoBrightness < 200));
return (
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/70 backdrop-blur transition-all duration-300">
<div
className={`mx-auto flex max-w-6xl items-center justify-between px-4 transition-all duration-300 ${
isScrolled ? "py-2" : "py-4"
}`}
>
<Link
href="/"
className={`brand-title flex items-center gap-3 font-semibold tracking-tight text-slate-900 transition-all duration-300 ${
isScrolled ? "text-base" : "text-lg"
}`}
>
{logoUrl && (
<img
src={logoUrl}
alt="Vereinskalender Logo"
className={`w-auto object-contain transition-all duration-300 ${
isScrolled ? "h-7 max-w-[140px]" : "h-12 max-w-[210px]"
}`}
style={shouldInvertLogo ? { filter: "invert(1)" } : undefined}
onError={() => setLogoUrl(null)}
/>
)}
<span>{appName}</span>
</Link>
<nav className="hidden items-center gap-3 text-sm md:flex">
{data?.user && (
<>
{isAdmin && (
<>
<Link href="/admin" className={linkClass("/admin")}>
Admin
</Link>
<Link href="/admin/users" className={linkClass("/admin/users")}>
Registrierungen
</Link>
</>
)}
<Link href="/settings" className={linkClass("/settings")}>
Einstellungen
</Link>
</>
)}
{data?.user ? (
<button
type="button"
onClick={() => signOut()}
className="btn-primary inline-flex items-center gap-2"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10 6h8a2 2 0 012 2v8a2 2 0 01-2 2h-8" strokeLinecap="round" />
<path d="M14 12H4m0 0l3-3M4 12l3 3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Logout
</button>
) : showLoginButton ? (
<button
type="button"
onClick={() => signIn()}
className="btn-accent"
>
Login
</button>
) : null}
</nav>
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600 md:hidden"
onClick={() => setMobileOpen((prev) => !prev)}
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
aria-expanded={mobileOpen}
>
<span className="relative block h-5 w-5">
<svg
viewBox="0 0 24 24"
className={`absolute inset-0 h-5 w-5 transition-all duration-300 ${
mobileOpen ? "rotate-90 opacity-0" : "rotate-0 opacity-100"
}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
</svg>
<svg
viewBox="0 0 24 24"
className={`absolute inset-0 h-5 w-5 transition-all duration-300 ${
mobileOpen ? "rotate-0 opacity-100" : "-rotate-90 opacity-0"
}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
</svg>
</span>
</button>
</div>
{mobileOpen && (
<div
className="fixed inset-0 z-10 bg-black/30 md:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
<div
className={`mobile-menu-panel relative z-20 overflow-hidden transition-all duration-300 md:hidden ${
mobileOpen ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
}`}
>
<div className="mx-auto max-w-6xl space-y-2 px-4 pb-4">
{data?.user && (
<>
{isAdmin && (
<>
<Link href="/admin" className="nav-link block rounded-xl px-3 py-2 text-sm">
Admin
</Link>
<Link
href="/admin/users"
className="nav-link block rounded-xl px-3 py-2 text-sm"
>
Registrierungen
</Link>
</>
)}
<Link href="/settings" className="nav-link block rounded-xl px-3 py-2 text-sm">
Einstellungen
</Link>
</>
)}
{data?.user ? (
<button
type="button"
onClick={() => signOut()}
className="btn-primary inline-flex w-full items-center justify-center gap-2"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10 6h8a2 2 0 012 2v8a2 2 0 01-2 2h-8" strokeLinecap="round" />
<path d="M14 12H4m0 0l3-3M4 12l3 3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Logout
</button>
) : showLoginButton ? (
<button
type="button"
onClick={() => signIn()}
className="btn-accent w-full"
>
Login
</button>
) : null}
</div>
</div>
</header>
);
}