Initial commit
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user