Files
2026-05-26 22:46:00 +02:00

223 lines
8.8 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Disc3, Search, FolderHeart, Tag, Settings, Trash2, Users, Building2, Film, Database, ChevronDown, Layers } from "lucide-react";
import { BRAND } from "@/lib/brand";
import { useSettingsPanel } from "@/components/settings/SettingsPanelProvider";
import { useTrashPanel } from "@/components/trash/TrashPanelProvider";
import { QueueIndicator } from "@/components/queue/QueueIndicator";
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "react";
export function TopNav() {
const pathname = usePathname();
const router = useRouter();
const { open: settingsOpen, toggle: toggleSettings } = useSettingsPanel();
const { open: trashOpen, toggle: toggleTrash } = useTrashPanel();
const [q, setQ] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = q.trim();
if (!trimmed) return;
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
};
const link = (href: string, label: string, Icon: React.ComponentType<{ className?: string }>) => {
const active = pathname === href || (href !== "/" && pathname.startsWith(href));
return (
<Link
href={href}
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors w-[110px]",
active ? "text-[var(--color-fg)] bg-[var(--color-glass-strong)]" : "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
)}
>
<Icon className="w-4 h-4" />
<span>{label}</span>
</Link>
);
};
return (
<header className="sticky top-0 z-40 border-b border-[var(--color-glass-border)] backdrop-blur-xl bg-[color-mix(in_oklch,var(--color-bg-0)_70%,transparent)]">
<div className="max-w-[1600px] mx-auto px-6 h-16 flex items-center gap-6">
<Link href="/" className="flex items-center gap-2 group">
<div className="relative">
<Disc3 className="w-5 h-5 text-[var(--color-cyan)] group-hover:rotate-90 transition-transform duration-700" />
<div className="absolute inset-0 blur-md bg-[var(--color-cyan)] opacity-50 -z-10" />
</div>
<span className="font-semibold tracking-tight text-gradient-accent text-lg">{BRAND.name}</span>
</Link>
<nav className="flex items-center gap-1">
{link("/", "Library", Disc3)}
{link("/actress", "Actress", Users)}
<DatabaseMenu pathname={pathname} />
{link("/category", "Categories", Layers)}
{link("/tag", "Tags", Tag)}
{link("/collection", "Collection", FolderHeart)}
</nav>
<div className="ml-auto flex items-center gap-2">
<form onSubmit={submit} className="w-full max-w-xs relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search Code, Title, Notes…"
className="w-full glass rounded-xl pl-10 pr-16 py-2 text-sm outline-none focus:border-[var(--color-cyan)] focus:shadow-[var(--shadow-glow-cyan)] transition-all placeholder:text-[var(--color-fg-muted)]"
/>
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] font-mono text-[var(--color-fg-muted)] glass px-1.5 py-0.5 rounded">
K
</kbd>
</form>
<QueueIndicator />
<button
onClick={toggleTrash}
aria-label="Trash"
aria-pressed={trashOpen}
title="Trash"
className={cn(
"w-9 h-9 grid place-items-center rounded-lg border transition-colors shrink-0",
trashOpen
? "bg-[var(--color-coral)]/15 border-[var(--color-coral)]/40 text-[var(--color-coral)]"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={toggleSettings}
aria-label="Settings"
aria-pressed={settingsOpen}
title="Settings"
className={cn(
"w-9 h-9 grid place-items-center rounded-lg border transition-colors shrink-0",
settingsOpen
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Settings className="w-4 h-4" />
</button>
</div>
</div>
</header>
);
}
const DATABASE_ITEMS: Array<{ href: string; label: string; Icon: React.ComponentType<{ className?: string }> }> = [
{ href: "/studios", label: "Studios", Icon: Building2 },
{ href: "/series", label: "Series", Icon: Film },
];
function DatabaseMenu({ pathname }: { pathname: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Small close-delay so moving the cursor from the trigger button into
// the dropdown (across the 1 px positioning offset) doesn't dismiss.
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const active = DATABASE_ITEMS.some((it) => pathname === it.href || pathname.startsWith(it.href + "/"));
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("mousedown", onClick);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("mousedown", onClick);
window.removeEventListener("keydown", onKey);
};
}, [open]);
useEffect(() => {
return () => { if (closeTimer.current) clearTimeout(closeTimer.current); };
}, []);
const cancelClose = () => {
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
};
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 120);
};
return (
<div
ref={ref}
className="relative"
onMouseEnter={() => { cancelClose(); setOpen(true); }}
onMouseLeave={scheduleClose}
>
<button
type="button"
onClick={() => setOpen((s) => !s)}
aria-haspopup="menu"
aria-expanded={open}
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors w-[110px]",
active || open
? "text-[var(--color-fg)] bg-[var(--color-glass-strong)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
)}
>
<Database className="w-4 h-4" />
<span>Database</span>
<ChevronDown className={cn("w-3 h-3 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
role="menu"
// pt-1 adds a hover bridge so the cursor can cross from the
// trigger to the menu items without leaving the wrapper.
className="absolute top-full left-0 pt-1 min-w-[160px] z-50"
>
<div className="rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-0)] shadow-lg backdrop-blur-xl overflow-hidden">
{DATABASE_ITEMS.map(({ href, label, Icon }) => {
const itemActive = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
onClick={() => setOpen(false)}
role="menuitem"
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors",
itemActive
? "text-[var(--color-cyan)] bg-[var(--color-glass)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Icon className="w-4 h-4" />
{label}
</Link>
);
})}
</div>
</div>
)}
</div>
);
}