2a258be3da
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>
277 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|