Files
slot/next-app/apps/web/src/components/Chrome/Header.tsx
T
chpark 2a258be3da PHP-parity: anon mb_level=1 + mega-menu separator-grouped columns
Board access (gnuboard common.php parity):
- gnuboard sets default mb_level=1 for anonymous visitors (common.php:675)
- React was treating anon as level 0, so bo_read_level=1 boards were blocking
  anonymous reads when PHP allowed them. Fix: checkBoardAccess() now coerces
  userLevel<1 to 1 before comparison
- Verified externally: anon /free /review /humor → 200 (read=1), /notice → 307 (read=2)

Mega-menu (포인트게임 layout fix):
- gnuboard's g5_eyoom_menu uses leaf separator labels like "─ 스포츠 ─" /
  "─ 미니게임 ─" / "─ 슬롯/릴 ─" with href='#' to visually group sub-items.
- MegaPanel now recognises these as section breaks: each separator starts a
  new column with the cleaned label as the column title, and following
  leaves attach to that section until the next separator.
- Fallback: items that have actual children still render as titled groups.
- Result: 포인트게임 hover now lays out as
    포인트 바카라 (loose) | 스포츠 | 미니게임 | 슬롯/릴 | (loose tail)
  instead of one giant column + scattered group headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:08:35 +09:00

277 lines
13 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bookmark, UserPlus, ShoppingBag, Plus, LogIn, MessageCircle, Mail, Moon, Menu as MenuIcon, ChevronDown, Search,
} from 'lucide-react';
import type { MenuItem, SiteUser } from '@slot/themes';
export interface HeaderProps {
user: SiteUser | null;
menus: MenuItem[];
isDark: boolean;
accent?: string;
}
export default function Header({ user, menus, isDark, accent }: HeaderProps) {
return (
<header className="sticky top-0 z-30 bg-white/85 backdrop-blur-md shadow-[0_1px_0_rgba(0,0,0,0.04)]">
<UtilityBar user={user} accent={accent} />
<BrandRow user={user} />
<MegaNav menus={menus} isDark={isDark} />
</header>
);
}
const ACCENTS = [
{ id: 'purple', name: 'Purple', color: '#7c3aed' },
{ id: 'blue', name: 'Blue', color: '#2563eb' },
{ id: 'teal', name: 'Teal', color: '#0d9488' },
{ id: 'sky', name: 'Sky', color: '#0284c7' },
{ id: 'emerald', name: 'Emerald', color: '#059669' },
{ id: 'amber', name: 'Amber', color: '#d97706' },
{ id: 'rose', name: 'Rose', color: '#e11d48' },
{ id: 'fuchsia', name: 'Fuchsia', color: '#c026d3' },
];
function AccentPicker({ accent }: { accent?: string }) {
const cur = accent || 'purple';
return (
<div className="inline-flex items-center gap-1 rounded-full border border-neutral-200 bg-white/80 px-2 py-1">
{ACCENTS.map((a) => (
<a
key={a.id}
href={`/api/ui/accent?t=${a.id}`}
title={a.name}
aria-label={`색상 ${a.name}`}
className={`block rounded-full transition ${cur === a.id ? 'h-5 w-5 ring-2 ring-offset-1' : 'h-4 w-4 opacity-80 hover:opacity-100 hover:scale-110'}`}
style={{ background: a.color, ['--tw-ring-color' as never]: a.color }}
/>
))}
</div>
);
}
function UtilityBar({ user, accent }: { user: SiteUser | null; accent?: string }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-neutral-100 text-[12px]">
<div className="mx-auto max-w-[1280px] flex items-center justify-between px-6 py-2">
<div className="flex items-center gap-3">
<Link href="/bookmarks" className="inline-flex items-center gap-1 text-neutral-text-soft hover:text-brand-600">
<Bookmark size={13} />
</Link>
<AccentPicker accent={accent} />
</div>
<div className="flex items-center gap-5 text-neutral-text-soft">
{user ? (
<Link href="/mypage" className="hover:text-brand-600">👤 {user.nick}</Link>
) : (
<Link href="/register" className="inline-flex items-center gap-1 hover:text-brand-600"><UserPlus size={13} /> </Link>
)}
{user ? null : (
<Link href="/login" className="inline-flex items-center gap-1 hover:text-brand-600"><LogIn size={13} /> </Link>
)}
<Link href="/mypage" className="inline-flex items-center gap-1 hover:text-brand-600"><ShoppingBag size={13} /> </Link>
<div className="relative" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
<button className="inline-flex items-center gap-1 hover:text-brand-600">
<Plus size={13} /> <ChevronDown size={12} />
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
className="absolute right-0 top-full mt-2 w-44 rounded-xl border border-neutral-100 bg-white p-1.5 shadow-[0_12px_32px_rgba(124,82,224,0.18)]"
>
<DropLink href="/new"></DropLink>
<DropLink href="/help/faq"> </DropLink>
<DropLink href="/help/qa">1:1문의</DropLink>
{user && <DropLink href="/mypage/profile"></DropLink>}
{user && (
<form action="/api/auth/logout" method="POST" className="m-0">
<button type="submit" className="block w-full rounded-lg px-3 py-2 text-left text-[13px] text-rose-600 hover:bg-rose-50"></button>
</form>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
);
}
function DropLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link href={href} className="block rounded-lg px-3 py-2 text-[13px] text-neutral-700 hover:bg-brand-50 hover:text-brand-700">
{children}
</Link>
);
}
function BrandRow({ user }: { user: SiteUser | null }) {
return (
<div className="bg-gradient-to-b from-white to-[#fbfaff]">
<div className="mx-auto flex max-w-[1280px] items-center justify-between gap-4 px-6 py-4">
<Link href="/" className="flex items-center gap-2 no-underline">
<span className="rounded-xl bg-gradient-to-br from-[#7c52e0] to-[#b794f4] px-4 py-2.5 text-2xl font-extrabold leading-none text-white shadow-[0_4px_14px_rgba(124,82,224,0.35)]"> </span>
<span className="rounded-md bg-amber-300 px-2 py-1 text-[11px] font-extrabold tracking-wide text-amber-900"></span>
</Link>
<form className="hidden flex-1 max-w-[420px] md:flex" action="/free/search" method="GET">
<div className="flex w-full items-center gap-2 rounded-full border border-brand-100 bg-white pl-4 pr-1.5 py-1.5 shadow-sm focus-within:border-brand-400 focus-within:shadow-[0_0_0_4px_rgba(167,139,250,0.18)]">
<Search size={16} className="text-neutral-400" />
<input name="stx" placeholder="게시판 검색…" className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-neutral-400" />
<button className="rounded-full bg-brand-600 px-4 py-1.5 text-[12px] font-semibold text-white hover:bg-brand-700"></button>
</div>
</form>
<div className="flex items-center gap-5">
{user ? (
<>
<IconLink href="/mypage" emoji="👤" label={user.nick} />
<IconLink href="/mypage/respond" Icon={MessageCircle} label="내글반응" badge={user.respondCount} />
<IconLink href="/memo" Icon={Mail} label="쪽지" badge={user.memoCount} />
</>
) : (
<>
<IconLink href="/login" Icon={LogIn} label="로그인" />
<IconLink href="/login" Icon={MessageCircle} label="내글반응" badge={0} />
<IconLink href="/login" Icon={Mail} label="쪽지" badge={0} />
</>
)}
</div>
</div>
</div>
);
}
function IconLink({ href, Icon, emoji, label, badge }: { href: string; Icon?: React.ComponentType<{ size?: number; className?: string }>; emoji?: string; label: string; badge?: number }) {
return (
<Link href={href} className="group relative flex min-w-[56px] flex-col items-center gap-1 text-[11px] text-neutral-text-soft transition hover:text-brand-700">
<span className="relative grid h-9 w-9 place-items-center rounded-full bg-brand-50 group-hover:bg-brand-100">
{emoji ? <span className="text-base">{emoji}</span> : Icon ? <Icon size={18} /> : null}
{typeof badge === 'number' && (
<span className="absolute -top-1 -right-1 grid h-4 min-w-4 place-items-center rounded-full bg-rose-500 px-1 text-[10px] font-bold text-white">{badge}</span>
)}
</span>
<span className="whitespace-nowrap">{label}</span>
</Link>
);
}
/** Group submenu items: items with children render as section headers with their kids inline below. */
function MegaPanel({ items }: { items: MenuItem[] }) {
// Two grouping strategies — pick whichever gnuboard's data hints at:
// (a) child-grouped: items with `children` become titled section columns
// (b) separator-grouped: a leaf with href='#' or label like "─ X ─" / "── X ──"
// starts a new section with that label as the column title (used by the
// gnuboard 슬생 data, which flattens 포인트게임 sub-menu)
const sections: { title?: string; href?: string; links: MenuItem[] }[] = [];
let cur: { title?: string; href?: string; links: MenuItem[] } = { links: [] };
function flush() { if (cur.links.length > 0 || cur.title) sections.push(cur); cur = { links: [] }; }
const isSeparator = (it: MenuItem) => {
const lab = (it.label || '').replace(/[─\-=ㅡ]/g, '').trim();
return (it.href === '#' || !it.href) && lab.length <= 8 && (it.label || '').match(/[─\-=ㅡ]/);
};
for (const it of items) {
if (it.children && it.children.length > 0) {
flush();
sections.push({ title: it.label, href: it.href, links: it.children });
} else if (isSeparator(it)) {
flush();
const label = (it.label || '').replace(/[─\-=ㅡ\s]/g, '').trim();
cur = { title: label || it.label, links: [] };
} else {
cur.links.push(it);
}
}
flush();
// Layout: pick column count by total sections; cap 4
const colCount = sections.length <= 1 ? 1 : sections.length === 2 ? 2 : sections.length === 3 ? 3 : Math.min(4, sections.length);
const colClass = colCount === 1 ? 'grid-cols-1' : colCount === 2 ? 'grid-cols-2' : colCount === 3 ? 'grid-cols-3' : 'grid-cols-4';
return (
<div className={`grid gap-x-5 gap-y-3 ${colClass}`} style={{ color: '#1f2937' }}>
{sections.map((s, si) => (
<div key={si} className="min-w-[160px]">
{s.title && (
<div className="mb-1 border-b border-brand-100 pb-1 text-[11px] font-bold uppercase tracking-widest" style={{ color: '#7c3aed' }}>
{s.href && s.href !== '#' ? <Link href={s.href} style={{ color: '#7c3aed' }}>{s.title}</Link> : s.title}
</div>
)}
<ul className="m-0 grid gap-0.5 p-0 list-none">
{s.links.map((c, ci) => (
<li key={ci}>
<Link
href={c.href || '#'}
style={{ color: '#1f2937', display: 'block' }}
className="rounded-lg px-3 py-2 text-[13.5px] font-medium transition whitespace-nowrap hover:!text-white hover:bg-gradient-to-r hover:from-violet-600 hover:to-fuchsia-600 hover:shadow-[0_4px_14px_rgba(124,82,224,0.25)]"
onMouseOver={(e) => { e.currentTarget.style.color = '#ffffff'; }}
onMouseOut={(e) => { e.currentTarget.style.color = '#1f2937'; }}
>
{c.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
);
}
function MegaNav({ menus, isDark: _isDark }: { menus: MenuItem[]; isDark: boolean }) {
const [open, setOpen] = useState<number | null>(null);
return (
<nav className="bg-mega text-white shadow-[0_4px_14px_rgba(108,76,209,0.35)]">
<div className="relative mx-auto flex max-w-[1280px] flex-nowrap items-center gap-1 px-6">
{menus.map((m, i) => {
const hasChildren = m.children && m.children.length > 0;
return (
<div
key={m.label + i}
className="relative"
onMouseEnter={() => setOpen(i)}
onMouseLeave={() => setOpen((v) => (v === i ? null : v))}
>
<Link
href={m.href || '#'}
style={{ color: '#ffffff', textShadow: '0 1px 2px rgba(0,0,0,0.35)' }}
className="relative z-10 flex items-center gap-1.5 whitespace-nowrap px-4 py-3.5 text-[14.5px] font-extrabold tracking-tight transition hover:bg-black/25 hover:shadow-[inset_0_-3px_0_0_#fde047]"
>
{m.icon && <span aria-hidden>{m.icon}</span>}
<span>{m.label}</span>
{hasChildren && <ChevronDown size={14} className="opacity-80" />}
</Link>
<AnimatePresence>
{open === i && hasChildren && (
<motion.div
initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 6 }} transition={{ duration: 0.16 }}
className="absolute left-0 top-full z-50 mt-0 rounded-2xl border border-brand-100 p-3 shadow-[0_18px_38px_rgba(60,30,120,0.22)]"
style={{ minWidth: 240, background: '#ffffff', color: '#1f2937' }}
>
<MegaPanel items={m.children!} />
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
<span className="ml-auto" />
<button title="모든 메뉴" className="grid h-10 w-10 place-items-center rounded-full hover:bg-white/10"><MenuIcon size={18} /></button>
<form action="/api/ui/dark-mode" method="POST" className="m-0">
<button title="다크모드" className="grid h-9 w-9 place-items-center rounded-full border border-white/30 hover:bg-white/10">
<Moon size={15} />
</button>
</form>
</div>
</nav>
);
}