294 lines
9.5 KiB
TypeScript
294 lines
9.5 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 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>
|
|
);
|
|
}
|