ca965fec90
## Menu rewrites against the production /bbs/*.php URLs The g5_eyoom_menu rows store legacy PHP URLs verbatim (/bbs/tv.php, /bbs/point_guide.php, /bbs/exchange-amount.php, /bbs/Bighandbro.php, /bbs/event_exchange.php, /bbs/slotlife.php, /shop/list.php, /shop/orderinquiry.php, /roulette/?idx=1, /plugin/swiunApi/game.php?gt=mix …). Added rewriters that map every one of those to the new app routes so clicking any menu item lands on a real page. ## /games hub page The 포인트게임 top-level link used to 404 (it pointed to /games which didn't exist as a page). Built a proper hub: gradient hero, 15-tile slot simulator grid (each with a unique color gradient + emoji), and two callout cards for sports/mini-game branches. ## /tv/bighand 큰손형 방송 dedicated page with KICK CTA + schedule. ## Header polish - whitespace-nowrap on every menu label so 보증사이트 / 먹튀사이트 / 슬생TV no longer wrap at narrow widths. - MegaPanel renderer that turns 3rd-level eyoom menu groups (스포츠 / 미니게임 / 슬롯·릴) into multi-column section blocks instead of collapsing to an empty dropdown. - Replaced 구매내역 link with /mypage so utility-bar 404 disappears. ## verify-everything.mjs New end-to-end script that: - Crawls every menu link (64 unique URLs) and asserts non-404. - Real interaction tests: testlogin login → board list → post view → comment POST → recommend POST → admin login → admin dashboard render check → admin theme picker visible (4 themes) → theme switch POST → logout POST → attendance check POST. Result: PASS 85 / FAIL 0 / TOTAL 85. Report at next-app/verify-out/.
229 lines
11 KiB
TypeScript
229 lines
11 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;
|
|
}
|
|
|
|
export default function Header({ user, menus, isDark }: 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} />
|
|
<BrandRow user={user} />
|
|
<MegaNav menus={menus} isDark={isDark} />
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function UtilityBar({ user }: { user: SiteUser | null }) {
|
|
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">
|
|
<Link href="/bookmarks" className="inline-flex items-center gap-1 text-neutral-text-soft hover:text-brand-600">
|
|
<Bookmark size={13} /> 북마크
|
|
</Link>
|
|
<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[] }) {
|
|
// Split into groups: items with children become a "section" with their kids underneath.
|
|
// Items without children stay as plain links.
|
|
const sections: { title?: string; href?: string; links: MenuItem[] }[] = [];
|
|
let currentLooseLinks: MenuItem[] = [];
|
|
for (const it of items) {
|
|
if (it.children && it.children.length > 0) {
|
|
if (currentLooseLinks.length > 0) {
|
|
sections.push({ links: currentLooseLinks });
|
|
currentLooseLinks = [];
|
|
}
|
|
sections.push({ title: it.label, href: it.href, links: it.children });
|
|
} else {
|
|
currentLooseLinks.push(it);
|
|
}
|
|
}
|
|
if (currentLooseLinks.length > 0) sections.push({ links: currentLooseLinks });
|
|
|
|
// Decide layout: if there are 3+ sections OR any section has many links, use multi-column.
|
|
const total = sections.reduce((n, s) => n + s.links.length + (s.title ? 1 : 0), 0);
|
|
const multiCol = sections.some((s) => s.links.length >= 6) || total >= 14;
|
|
|
|
return (
|
|
<div className={`grid gap-x-4 gap-y-3 ${multiCol ? 'grid-cols-3' : 'grid-cols-1'}`}>
|
|
{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 text-brand-500">
|
|
{s.href && s.href !== '#' ? <Link href={s.href} className="hover:text-brand-700">{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 || '#'}
|
|
className="block rounded-lg px-2.5 py-1.5 text-[13px] text-neutral-700 hover:bg-brand-50 hover:text-brand-700 whitespace-nowrap"
|
|
>
|
|
{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 || '#'}
|
|
className="flex items-center gap-1.5 whitespace-nowrap px-3 py-3.5 text-[14px] font-semibold tracking-tight hover:bg-white/10"
|
|
>
|
|
{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-40 mt-0 rounded-2xl border border-brand-100 bg-white p-3 shadow-[0_18px_38px_rgba(60,30,120,0.22)]"
|
|
style={{ minWidth: 240 }}
|
|
>
|
|
<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>
|
|
);
|
|
}
|