Files
buzz/app/(ui)/admin/navbar.tsx
T
dmgnr 8c14999c59
Build and Push Image / build-backend (push) Successful in 6m13s
Build and Push Image / build-frontend (push) Successful in 7m33s
Build and Push Image / push (push) Successful in 1m16s
feat(admin): tierlist editor
2026-08-06 03:00:13 +07:00

367 lines
11 KiB
TypeScript

"use client";
import {
AlignJustify,
BadgeDollarSign,
BitcoinIcon,
ChevronRight,
Columns3Cog,
Compass,
ExternalLink,
Form,
Grid3x3,
Home,
IdCard,
Layout,
List,
Package,
Plus,
ScrollText,
Server,
Settings,
Table,
UserSquare2,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
type NavbarEntry = {
name: string;
icon: ComponentType<{ size?: number; name?: string; className?: string }>;
divide?: boolean;
last?: boolean;
} & ({ href: string; sub?: never } | { sub: NavbarEntry[]; href?: never });
function VersionBadge({
name,
className,
}: {
name?: string;
className?: string;
}) {
return (
<span className={cn("text-[10px] leading-none font-bold", className)}>
{name?.match(/[0-9.]+$/)?.[1]}
</span>
);
}
function isActive(entry: NavbarEntry, pathname: string): boolean {
if ("href" in entry && entry.href)
return pathname === entry.href || pathname.startsWith(`${entry.href}/`);
if ("sub" in entry && entry.sub)
return entry.sub.some((s) => isActive(s, pathname));
return false;
}
function findActiveParent(
items: NavbarEntry[],
pathname: string,
): NavbarEntry | null {
for (const item of items) {
if ("sub" in item && item.sub) {
if (item.sub.some((s) => isActive(s, pathname))) return item;
}
}
return null;
}
function DropdownMenu({
items,
showLabels,
onClose,
depth = 0,
}: {
items: NavbarEntry[];
showLabels: boolean;
onClose: () => void;
depth?: number;
}) {
const pathname = usePathname();
const [openSub, setOpenSub] = useState<string | null>(null);
return (
<div
className={cn(
"flex min-w-40 flex-col gap-0.5",
depth > 0 && "ml-2 border-l border-white/10 pl-2",
)}
>
{items.map((item) => {
const Icon = item.icon;
const active = isActive(item, pathname);
const hasSub = "sub" in item && item.sub;
const isOpen = openSub === item.name;
return (
<div key={item.name}>
{hasSub ? (
<>
<button
type="button"
onClick={() => setOpenSub(isOpen ? null : item.name)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left",
"text-[13px] transition-colors",
active
? "bg-white/15 text-white"
: "text-white/60 hover:bg-white/10 hover:text-white",
)}
>
<Icon size={16} name={item.name} className="shrink-0" />
<span className="flex-1">{item.name}</span>
<ChevronRight
size={14}
className={cn(
"transition-transform",
isOpen && "rotate-90",
)}
/>
</button>
{isOpen && (
<DropdownMenu
items={item.sub!}
showLabels={showLabels}
onClose={onClose}
depth={depth + 1}
/>
)}
</>
) : (
<Link
href={item.href!}
onClick={onClose}
className={cn(
"flex items-center gap-2 rounded-md px-2.5 py-2",
"text-[13px] transition-colors",
active
? "bg-white/15 text-white"
: "text-white/60 hover:bg-white/10 hover:text-white",
)}
>
<Icon size={16} name={item.name} className="shrink-0" />
<span>{item.name}</span>
</Link>
)}
</div>
);
})}
</div>
);
}
export function AdminNavbar({
adminShortcuts,
tierlistTypes,
tierlistVersions,
}: {
adminShortcuts: {
name: string;
url: string;
}[];
tierlistTypes: {
id: string;
name: string;
count: number;
}[];
tierlistVersions: {
id: string;
name: string;
}[];
}) {
const pathname = usePathname();
const isMobile = useIsMobile();
const [showLabels, setShowLabels] = useState(false);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const navRef = useRef<HTMLDivElement>(null);
const items: NavbarEntry[] = [
{ name: "หน้าหลัก", href: "/admin", icon: Home, divide: true },
{
name: "Tierlist",
icon: Table,
sub: [
{ name: "Layout", icon: Columns3Cog, href: "/admin/tl/layout" },
...tierlistTypes.map((type) => ({
name: type.name,
icon: Layout,
href: `/admin/tl/${type.id}`,
})),
],
},
{ name: "Guide", icon: Compass, href: "/admin/guide" },
{
name: "Versions",
icon: List,
sub: [
{ name: "Types", icon: Form, href: "/admin/ver/types" },
{ name: "Create", icon: Plus, href: "/admin/ver/create" },
...tierlistVersions.map((v) => ({
name: v.name,
icon: VersionBadge,
href: `/admin/ver/${v.id}`,
})),
],
},
{
name: "Global",
icon: Server,
sub: [
{
name: "Admin Pages",
icon: ExternalLink,
sub: [
{ name: "Artifact", icon: IdCard, href: "/artifact/admin" },
{ name: "Rubgram", icon: BadgeDollarSign, href: "/rubgram/admin" },
{ name: "Donate", icon: BitcoinIcon, href: "/donate/admin" },
{
name: "Tierlist",
icon: Grid3x3,
sub: adminShortcuts.map((s) => ({
name: s.name,
icon: VersionBadge,
href: `/tl/${s.url}/admin`,
})),
},
],
},
{ name: "Characters", icon: UserSquare2, href: "/admin/char" },
{ name: "CDN", icon: Package, href: "/admin/cdn" },
{ name: "Audit Log", icon: ScrollText, href: "/admin/log" },
{ name: "Settings", icon: Settings, href: "/admin/settings" },
],
},
];
const [scopeOverride, setScopeOverride] = useState<"root" | null>(null);
// Reset override whenever pathname changes (navigating resets scope)
// biome-ignore lint/correctness/useExhaustiveDependencies: used for other purepose
useEffect(() => {
setScopeOverride(null);
}, [pathname]);
const activeParent =
scopeOverride === "root" ? null : findActiveParent(items, pathname);
const prefixParent =
scopeOverride === "root" ? findActiveParent(items, pathname) : activeParent;
useEffect(() => {
function handleClick(e: MouseEvent) {
if (navRef.current && !navRef.current.contains(e.target as Node)) {
setOpenDropdown(null);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
if (!isMobile) return "";
return (
<div ref={navRef} className="sticky top-0 z-49">
<div className="flex items-center gap-1 border-b border-white/10 bg-card/75 px-2 py-1.5 backdrop-blur-sm">
<div className="flex shrink-0 items-center gap-1">
{prefixParent && (
<button
type="button"
onClick={() =>
setScopeOverride(scopeOverride === "root" ? null : "root")
}
className={cn(
"flex h-8 w-6 items-center justify-center rounded opacity-50 transition-colors hover:opacity-100",
scopeOverride === "root" && "bg-white/15 opacity-100",
)}
>
<prefixParent.icon size={14} name={prefixParent.name} />
</button>
)}
<button
type="button"
onClick={() => setShowLabels((v) => !v)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded transition-colors",
showLabels
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/70",
)}
>
{showLabels ? <X size={16} /> : <AlignJustify size={16} />}
</button>
<div className="mx-1 h-5 w-px bg-white/10" />
</div>
<div className="flex min-w-0 flex-1 scrollbar-none items-center gap-1 overflow-x-auto">
{(activeParent ? activeParent.sub! : items).map((item) => {
const Icon = item.icon;
const active = isActive(item, pathname);
const hasSub = "sub" in item && item.sub;
const isOpen = openDropdown === item.name;
const buttonContent = (
<>
<Icon size={18} name={item.name} className="shrink-0" />
{showLabels && (
<span className="text-[11px] leading-none whitespace-nowrap">
{item.name}
</span>
)}
</>
);
const buttonClass = cn(
"flex shrink-0 items-center gap-1.5 rounded px-2.5 py-1.5 transition-colors",
active || isOpen
? "bg-white/15 text-white"
: "text-white/50 hover:bg-white/10 hover:text-white",
);
if (hasSub) {
return (
<button
key={item.name}
onClick={() => setOpenDropdown(isOpen ? null : item.name)}
className={buttonClass}
type="button"
>
{buttonContent}
</button>
);
}
return (
<Link key={item.name} href={item.href!} className={buttonClass}>
{buttonContent}
</Link>
);
})}
</div>
</div>
{openDropdown &&
(() => {
const entry = (activeParent ? activeParent.sub! : items).find(
(i) => i.name === openDropdown,
);
if (!entry || !("sub" in entry) || !entry.sub) return null;
return (
<div className="absolute top-full right-0 left-0 z-50 mt-1">
<div className="rounded-lg border border-white/10 bg-card/95 p-2 shadow-xl backdrop-blur-sm">
<DropdownMenu
items={entry.sub}
showLabels={showLabels}
onClose={() => setOpenDropdown(null)}
/>
</div>
</div>
);
})()}
</div>
);
}