Mega-menu re-layout + per-board level inline edit + 8-color accent picker
Mega-menu (Header MegaPanel):
- Was: 3-col grid for all sections, leading to broken-looking layout when
the loose-leaves count was low (1 leaf in column 1, group titles in 2/3)
- Now: leaves first as a single column, group sections after; column count
scales 1/2/3/4 by section count, capped at 4
/admin/boards (gnuboard parity):
- Inline read/write/comment Lv editor per row (3 number inputs + apply)
- Bulk "전체 적용" amber banner: set the same 3 levels across every g5_board
in one transaction (e.g. read=1/write=2/comment=2 → consistent per-site)
- /admin/boards/[bo_table]/edit still available via "상세" link for the
remaining 89 columns
Accent picker (like the localhost:9771 reference):
- /api/ui/accent?t={blue|teal|purple|rose|amber|emerald|sky|fuchsia}
- slot_accent cookie persists for 90d; layout reads it and overrides the
theme primary, plus rewrites .bg-mega gradient inline so the mega-nav
immediately reflects the chosen color
- Header utility-bar shows 8 color dots (current color highlighted ring)
- data-accent attribute on <html> for any future per-accent CSS rules
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { legacySql } from '@slot/db/legacy';
|
||||
import { updateBoardSubject } from './actions';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -10,12 +13,22 @@ interface Row {
|
||||
bo_count_write: number;
|
||||
bo_count_comment: number;
|
||||
bo_use_secret: number;
|
||||
bo_read_level: number;
|
||||
bo_write_level: number;
|
||||
bo_comment_level: number;
|
||||
}
|
||||
|
||||
async function requireAdmin() {
|
||||
const u = await getCurrentSiteUser();
|
||||
if (!u || (u.level ?? 0) < 10) redirect('/');
|
||||
}
|
||||
|
||||
export default async function BoardsAdmin() {
|
||||
await requireAdmin();
|
||||
const rows = await legacySql<Row[]>`
|
||||
SELECT bo_table, bo_subject, gr_id, bo_count_write, bo_count_comment, bo_use_secret
|
||||
FROM inspection2.g5_board ORDER BY bo_count_write DESC NULLS LAST LIMIT 100
|
||||
SELECT bo_table, bo_subject, gr_id, bo_count_write, bo_count_comment,
|
||||
bo_use_secret, bo_read_level, bo_write_level, bo_comment_level
|
||||
FROM inspection2.g5_board ORDER BY bo_count_write DESC NULLS LAST LIMIT 200
|
||||
`.catch(() => []);
|
||||
|
||||
async function renameBoard(formData: FormData) {
|
||||
@@ -25,45 +38,93 @@ export default async function BoardsAdmin() {
|
||||
await updateBoardSubject(tbl, sub);
|
||||
}
|
||||
|
||||
async function setLevels(formData: FormData) {
|
||||
'use server';
|
||||
await requireAdmin();
|
||||
const slug = String(formData.get('bo_table') ?? '').slice(0, 30);
|
||||
if (!/^[a-z0-9_]+$/i.test(slug)) return;
|
||||
const r = Math.max(0, Math.min(12, Number(formData.get('read_level') ?? 1) | 0));
|
||||
const w = Math.max(0, Math.min(12, Number(formData.get('write_level') ?? 1) | 0));
|
||||
const c = Math.max(0, Math.min(12, Number(formData.get('comment_level') ?? 1) | 0));
|
||||
await legacySql`
|
||||
UPDATE inspection2.g5_board
|
||||
SET bo_read_level = ${r}, bo_write_level = ${w}, bo_comment_level = ${c}
|
||||
WHERE bo_table = ${slug}
|
||||
`.catch(() => {});
|
||||
revalidatePath('/admin/boards');
|
||||
revalidatePath('/' + slug);
|
||||
}
|
||||
|
||||
async function applyDefaultsAll(formData: FormData) {
|
||||
'use server';
|
||||
await requireAdmin();
|
||||
const r = Math.max(0, Math.min(12, Number(formData.get('all_r') ?? 1) | 0));
|
||||
const w = Math.max(0, Math.min(12, Number(formData.get('all_w') ?? 2) | 0));
|
||||
const c = Math.max(0, Math.min(12, Number(formData.get('all_c') ?? 2) | 0));
|
||||
await legacySql`
|
||||
UPDATE inspection2.g5_board SET bo_read_level = ${r}, bo_write_level = ${w}, bo_comment_level = ${c}
|
||||
`.catch(() => {});
|
||||
revalidatePath('/admin/boards');
|
||||
}
|
||||
|
||||
return (
|
||||
<article>
|
||||
<header className="mb-5 border-b border-neutral-100 pb-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">게시판관리</div>
|
||||
<h1 className="mt-1 text-[22px] font-bold text-neutral-900">게시판 관리 ({rows.length})</h1>
|
||||
<p className="mt-1.5 text-[13px] text-neutral-text-soft">제목 즉시 변경 가능. 글/댓글 카운트는 g5_board의 캐시값.</p>
|
||||
<p className="mt-1.5 text-[13px] text-neutral-text-soft">제목·접근권한(읽기/쓰기/댓글) 즉시 변경. 더 많은 옵션은 "상세설정" 진입.</p>
|
||||
</header>
|
||||
|
||||
<form action={applyDefaultsAll} className="mb-4 flex flex-wrap items-end gap-2 rounded-xl bg-amber-50 p-3 ring-1 ring-amber-200">
|
||||
<div className="text-[12px] font-bold text-amber-800">⚠️ 모든 게시판에 일괄 적용:</div>
|
||||
<label className="flex items-center gap-1 text-[11px]">읽기<input name="all_r" type="number" defaultValue={1} min={0} max={12} className="w-12 rounded border border-amber-300 px-1 py-0.5 text-center" /></label>
|
||||
<label className="flex items-center gap-1 text-[11px]">쓰기<input name="all_w" type="number" defaultValue={2} min={0} max={12} className="w-12 rounded border border-amber-300 px-1 py-0.5 text-center" /></label>
|
||||
<label className="flex items-center gap-1 text-[11px]">댓글<input name="all_c" type="number" defaultValue={2} min={0} max={12} className="w-12 rounded border border-amber-300 px-1 py-0.5 text-center" /></label>
|
||||
<button type="submit" className="rounded bg-amber-600 px-3 py-1 text-[11px] font-bold text-white hover:bg-amber-700">전체 적용</button>
|
||||
<span className="text-[10.5px] text-amber-700">(0=비회원, 1=정회원, 2~9 일반, 10~12=관리자)</span>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-neutral-100 bg-white">
|
||||
<table className="w-full border-collapse text-[12.5px]">
|
||||
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">그룹</th>
|
||||
<th className="px-3 py-2 text-left">슬러그</th>
|
||||
<th className="px-3 py-2 text-left">제목 (변경 가능)</th>
|
||||
<th className="px-3 py-2 text-right">글</th>
|
||||
<th className="px-3 py-2 text-right">댓글</th>
|
||||
<th className="px-3 py-2 text-center">비밀</th>
|
||||
<th className="px-3 py-2 text-center">바로가기</th>
|
||||
<th className="px-2 py-2 text-left">그룹</th>
|
||||
<th className="px-2 py-2 text-left">슬러그</th>
|
||||
<th className="px-2 py-2 text-left">제목</th>
|
||||
<th className="px-2 py-2 text-center" colSpan={4}>접근권한 Lv (읽기 / 쓰기 / 댓글 / 적용)</th>
|
||||
<th className="px-2 py-2 text-right">글</th>
|
||||
<th className="px-2 py-2 text-right">댓글</th>
|
||||
<th className="px-2 py-2 text-center">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.bo_table} className="border-t border-neutral-100 hover:bg-neutral-50/60">
|
||||
<td className="px-3 py-2 text-[11px] text-neutral-500">{r.gr_id}</td>
|
||||
<td className="px-3 py-2 font-mono text-[11.5px]">{r.bo_table}</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-2 py-2 text-[11px] text-neutral-500">{r.gr_id}</td>
|
||||
<td className="px-2 py-2 font-mono text-[11px]">{r.bo_table}</td>
|
||||
<td className="px-2 py-2">
|
||||
<form action={renameBoard} className="flex items-center gap-1">
|
||||
<input type="hidden" name="bo_table" value={r.bo_table} />
|
||||
<input name="bo_subject" defaultValue={r.bo_subject} className="flex-1 rounded border border-neutral-200 px-2 py-1 text-[12px]" />
|
||||
<button type="submit" className="rounded bg-brand-600 px-2 py-1 text-[10px] font-bold text-white">저장</button>
|
||||
<input name="bo_subject" defaultValue={r.bo_subject} className="flex-1 rounded border border-neutral-200 px-2 py-1 text-[11.5px]" />
|
||||
<button type="submit" className="rounded bg-brand-600 px-2 py-1 text-[10px] font-bold text-white">↻</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular">{r.bo_count_write?.toLocaleString() ?? 0}</td>
|
||||
<td className="px-3 py-2 text-right tabular">{r.bo_count_comment?.toLocaleString() ?? 0}</td>
|
||||
<td className="px-3 py-2 text-center">{r.bo_use_secret ? '✓' : '–'}</td>
|
||||
<td className="px-3 py-2 text-center text-[11px]">
|
||||
<a href={`/admin/boards/${r.bo_table}/edit`} className="mr-2 rounded bg-brand-50 px-2 py-1 font-bold text-brand-700 hover:bg-brand-100">상세설정</a>
|
||||
<a href={`/${r.bo_table}`} target="_blank" className="text-brand-700 hover:underline">/{r.bo_table}</a>
|
||||
<td colSpan={4} className="px-2 py-2">
|
||||
<form action={setLevels} className="flex items-center gap-1">
|
||||
<input type="hidden" name="bo_table" value={r.bo_table} />
|
||||
<input name="read_level" type="number" min={0} max={12} defaultValue={Number(r.bo_read_level ?? 1)} className="w-11 rounded border border-neutral-200 px-1 py-0.5 text-center text-[11px] tabular" title="읽기" />
|
||||
<span className="text-[9px] text-neutral-400">/</span>
|
||||
<input name="write_level" type="number" min={0} max={12} defaultValue={Number(r.bo_write_level ?? 1)} className="w-11 rounded border border-neutral-200 px-1 py-0.5 text-center text-[11px] tabular" title="쓰기" />
|
||||
<span className="text-[9px] text-neutral-400">/</span>
|
||||
<input name="comment_level" type="number" min={0} max={12} defaultValue={Number(r.bo_comment_level ?? 1)} className="w-11 rounded border border-neutral-200 px-1 py-0.5 text-center text-[11px] tabular" title="댓글" />
|
||||
<button type="submit" className="rounded bg-emerald-600 px-2 py-0.5 text-[10px] font-bold text-white">적용</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right tabular">{r.bo_count_write?.toLocaleString() ?? 0}</td>
|
||||
<td className="px-2 py-2 text-right tabular">{r.bo_count_comment?.toLocaleString() ?? 0}</td>
|
||||
<td className="px-2 py-2 text-center text-[10.5px]">
|
||||
<a href={`/admin/boards/${r.bo_table}/edit`} className="mr-1 rounded bg-brand-50 px-1.5 py-0.5 font-bold text-brand-700 hover:bg-brand-100">상세</a>
|
||||
<a href={`/${r.bo_table}`} target="_blank" className="text-brand-700 hover:underline">↗</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const ACCENTS = ['blue', 'teal', 'purple', 'rose', 'amber', 'emerald', 'sky', 'fuchsia'] as const;
|
||||
export type Accent = typeof ACCENTS[number];
|
||||
|
||||
const VALID = new Set<string>(ACCENTS);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const t = (url.searchParams.get('t') ?? '').toLowerCase();
|
||||
const accent = VALID.has(t) ? t : 'purple';
|
||||
const ref = req.headers.get('referer');
|
||||
const target = new URL(ref ?? '/', req.url);
|
||||
const res = NextResponse.redirect(target, { status: 303 });
|
||||
res.cookies.set('slot_accent', accent, { path: '/', maxAge: 60 * 60 * 24 * 90, sameSite: 'lax' });
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const form = await req.formData();
|
||||
const t = String(form.get('accent') ?? '').toLowerCase();
|
||||
const accent = VALID.has(t) ? t : 'purple';
|
||||
const ref = req.headers.get('referer');
|
||||
const target = new URL(ref ?? '/', req.url);
|
||||
const res = NextResponse.redirect(target, { status: 303 });
|
||||
res.cookies.set('slot_accent', accent, { path: '/', maxAge: 60 * 60 * 24 * 90, sameSite: 'lax' });
|
||||
return res;
|
||||
}
|
||||
@@ -19,13 +19,28 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
const c = await cookies();
|
||||
const isDark = c.get('slot_dark')?.value === '1';
|
||||
const themeId = c.get('slot_theme')?.value ?? 'eyoom';
|
||||
const accentId = c.get('slot_accent')?.value ?? '';
|
||||
const themeAccent: Record<string, { primary: string; primaryDark: string; bg: string }> = {
|
||||
basic: { primary: '#2563eb', primaryDark: '#1d4ed8', bg: '#f8fafc' },
|
||||
eyoom: { primary: '#7c3aed', primaryDark: '#6d28d9', bg: '#f7f5fb' },
|
||||
amina: { primary: '#0ea5e9', primaryDark: '#0369a1', bg: '#f8fafc' },
|
||||
youngcart: { primary: '#ea580c', primaryDark: '#c2410c', bg: '#fff7ed' },
|
||||
};
|
||||
const t = themeAccent[themeId] ?? themeAccent.eyoom!;
|
||||
// Accent override: when slot_accent cookie is set it overrides the theme primary
|
||||
const ACCENT_PALETTE: Record<string, { primary: string; primaryDark: string }> = {
|
||||
blue: { primary: '#2563eb', primaryDark: '#1d4ed8' },
|
||||
teal: { primary: '#0d9488', primaryDark: '#115e59' },
|
||||
purple: { primary: '#7c3aed', primaryDark: '#6d28d9' },
|
||||
rose: { primary: '#e11d48', primaryDark: '#9f1239' },
|
||||
amber: { primary: '#d97706', primaryDark: '#92400e' },
|
||||
emerald: { primary: '#059669', primaryDark: '#065f46' },
|
||||
sky: { primary: '#0284c7', primaryDark: '#075985' },
|
||||
fuchsia: { primary: '#c026d3', primaryDark: '#86198f' },
|
||||
};
|
||||
const base = themeAccent[themeId] ?? themeAccent.eyoom!;
|
||||
const t = accentId && ACCENT_PALETTE[accentId]
|
||||
? { primary: ACCENT_PALETTE[accentId]!.primary, primaryDark: ACCENT_PALETTE[accentId]!.primaryDark, bg: base.bg }
|
||||
: base;
|
||||
const [user, popularTags, rankings, menus, visitors] = await Promise.all([
|
||||
getCurrentSiteUser(),
|
||||
getPopularTags(),
|
||||
@@ -45,12 +60,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
const hideSidebar = pathname.startsWith('/admin') || pathname.startsWith('/mypage') || pathname === '/login' || pathname === '/register';
|
||||
|
||||
return (
|
||||
<html lang="ko" className={isDark ? 'dark' : ''} data-theme={themeId}>
|
||||
<html lang="ko" className={isDark ? 'dark' : ''} data-theme={themeId} data-accent={accentId || 'default'}>
|
||||
<head>
|
||||
<style>{`:root{--theme-primary:${t.primary};--theme-primary-dark:${t.primaryDark};--theme-bg:${t.bg};}body{background:var(--theme-bg);}`}</style>
|
||||
<style>{`:root{--theme-primary:${t.primary};--theme-primary-dark:${t.primaryDark};--theme-bg:${t.bg};}body{background:var(--theme-bg);}.bg-mega{background:linear-gradient(90deg,${t.primary} 0%,${t.primaryDark} 100%);}`}</style>
|
||||
</head>
|
||||
<body>
|
||||
{!hideEverything && <Header user={user} menus={menus} isDark={isDark} />}
|
||||
{!hideEverything && <Header user={user} menus={menus} isDark={isDark} accent={accentId} />}
|
||||
<div className="mx-auto max-w-[1280px] px-6 py-6">
|
||||
<div className={hideSidebar ? '' : 'grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_300px]'}>
|
||||
<main className="min-h-[60vh] min-w-0">{children}</main>
|
||||
|
||||
@@ -11,26 +11,59 @@ export interface HeaderProps {
|
||||
user: SiteUser | null;
|
||||
menus: MenuItem[];
|
||||
isDark: boolean;
|
||||
accent?: string;
|
||||
}
|
||||
|
||||
export default function Header({ user, menus, isDark }: HeaderProps) {
|
||||
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} />
|
||||
<UtilityBar user={user} accent={accent} />
|
||||
<BrandRow user={user} />
|
||||
<MegaNav menus={menus} isDark={isDark} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function UtilityBar({ user }: { user: SiteUser | null }) {
|
||||
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">
|
||||
<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-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>
|
||||
@@ -131,29 +164,27 @@ function IconLink({ href, Icon, emoji, label, badge }: { href: string; Icon?: Re
|
||||
|
||||
/** 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[] = [];
|
||||
// Group leaves into a "MAIN" section then add each titled child group as its own column.
|
||||
const looseLeaves: MenuItem[] = [];
|
||||
const groupSections: { title: string; href?: string; links: 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 });
|
||||
groupSections.push({ title: it.label, href: it.href, links: it.children });
|
||||
} else {
|
||||
currentLooseLinks.push(it);
|
||||
looseLeaves.push(it);
|
||||
}
|
||||
}
|
||||
if (currentLooseLinks.length > 0) sections.push({ links: currentLooseLinks });
|
||||
// Always show loose leaves first as a single "전체" column, then group columns.
|
||||
const sections: { title?: string; href?: string; links: MenuItem[] }[] = [];
|
||||
if (looseLeaves.length > 0) sections.push({ links: looseLeaves });
|
||||
sections.push(...groupSections);
|
||||
|
||||
// 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;
|
||||
// Layout: 1 col when 1-2 sections, 2 col when 3, 3 col when 4+ but never more than 4 cols.
|
||||
const colCount = sections.length <= 2 ? 1 : sections.length === 3 ? 2 : 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-4 gap-y-3 ${multiCol ? 'grid-cols-3' : 'grid-cols-1'}`} style={{ color: '#1f2937' }}>
|
||||
<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 && (
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
// Playwright headless: log in as admin/test1234 and walk every admin path.
|
||||
// Then log in as testlogin/test1234 and walk every user-side page.
|
||||
// All against http://103.31.14.201:8088.
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const REACT = process.env.REACT_BASE || 'http://103.31.14.201:8088';
|
||||
|
||||
const ADMIN_PATHS = [
|
||||
'/admin', '/admin/members', '/admin/boards', '/admin/themes',
|
||||
'/admin/config/auth', '/admin/config/maintenance', '/admin/config/clean', '/admin/config/popups',
|
||||
'/admin/boards/groups', '/admin/boards/faq', '/admin/boards/contents', '/admin/boards/qa-config',
|
||||
'/admin/boards/popular', '/admin/boards/popular-rank', '/admin/boards/parsing',
|
||||
'/admin/boards/wrfixed', '/admin/boards/write-count', '/admin/boards/free/edit',
|
||||
'/admin/shop/items', '/admin/shop/config', '/admin/shop/categories', '/admin/shop/coupons',
|
||||
'/admin/shop/orders', '/admin/shop/sendcost', '/admin/shop/banners',
|
||||
'/admin/shop/brands', '/admin/shop/couponzone', '/admin/shop/buylist',
|
||||
'/admin/shop/item-options', '/admin/shop/item-events', '/admin/shop/personalpay',
|
||||
'/admin/shop/stocksms', '/admin/shop/examount', '/admin/shop/expoint',
|
||||
'/admin/eyoom/menu', '/admin/eyoom/yellowcard', '/admin/eyoom/managers', '/admin/eyoom/biz-info',
|
||||
'/admin/eyoom/main-layout', '/admin/eyoom/tags', '/admin/eyoom/attendance',
|
||||
'/admin/eyoom/themes', '/admin/eyoom/config', '/admin/eyoom/boards', '/admin/eyoom/shopmenu',
|
||||
'/admin/eyoom/ebslider', '/admin/eyoom/ebcontents', '/admin/eyoom/eblatest', '/admin/eyoom/ebbanner',
|
||||
'/admin/eyoom/level', '/admin/eyoom/memo', '/admin/eyoom/activity',
|
||||
'/admin/sms/config', '/admin/sms/write', '/admin/sms/history', '/admin/sms/history-num',
|
||||
'/admin/sms/hp', '/admin/sms/hp-group', '/admin/sms/hp-file', '/admin/sms/emoticon',
|
||||
'/admin/sms/emoticon-group', '/admin/sms/member-update',
|
||||
'/admin/members/visits', '/admin/members/poll', '/admin/members/visit-search',
|
||||
'/admin/members/funnels', '/admin/members/mail', '/admin/members/visit-delete',
|
||||
'/admin/members/point-compress',
|
||||
'/admin/plugin/sns', '/admin/plugin/recaptcha', '/admin/plugin/board-manage',
|
||||
'/admin/plugin/browscap', '/admin/plugin/visit-convert',
|
||||
'/admin/plugin/chatbot', '/admin/plugin/chatbot-feedback',
|
||||
'/admin/roulette', '/admin/roulette/rewards', '/admin/roulette/chances',
|
||||
'/admin/lottery/winners', '/admin/seo',
|
||||
];
|
||||
const USER_PATHS = [
|
||||
'/', '/free', '/review', '/mukti', '/humor', '/pick', '/notice', '/guarantee', '/lottery_ticket',
|
||||
'/mypage', '/mypage/posts', '/mypage/scrap', '/mypage/respond', '/mypage/profile',
|
||||
'/mypage/follower', '/mypage/following', '/mypage/activity', '/mypage/password',
|
||||
'/memo', '/shop', '/games', '/games/fortunes/play', '/wallet/charge', '/auth/cert',
|
||||
'/tag/' + encodeURIComponent('슬롯'),
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const failures = [];
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
|
||||
console.log(`▶ Playwright headless against ${REACT}\n`);
|
||||
|
||||
// Admin login
|
||||
await page.goto(REACT + '/login');
|
||||
await page.fill('input[name="loginId"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'test1234');
|
||||
await Promise.all([page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 12000 }).catch(() => {}), page.click('button[type="submit"]')]);
|
||||
console.log(' ✅ admin login → ' + page.url());
|
||||
|
||||
// Walk admin paths
|
||||
for (const p of ADMIN_PATHS) {
|
||||
try {
|
||||
const resp = await page.goto(REACT + p, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const status = resp?.status() ?? 0;
|
||||
if (status < 200 || status >= 400) { fail++; failures.push(`${p} HTTP ${status}`); console.log(` ❌ admin ${p} HTTP ${status}`); continue; }
|
||||
const html = await page.content();
|
||||
const hasKr = /[가-힣]/.test(html);
|
||||
if (hasKr && html.length > 1500) { pass++; console.log(` ✅ admin ${p}`); }
|
||||
else { fail++; failures.push(`${p} no content`); console.log(` ❌ admin ${p} (${html.length} bytes, kr=${hasKr})`); }
|
||||
} catch (e) { fail++; failures.push(`${p} ${e.message.split('\n')[0]}`); console.log(` ❌ admin ${p} ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
|
||||
// Theme cookie round-trip (4 themes) via redirect API
|
||||
for (const t of ['basic', 'amina', 'youngcart', 'eyoom']) {
|
||||
try {
|
||||
await page.goto(REACT + `/api/ui/theme?t=${t}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(REACT + '/', { waitUntil: 'domcontentloaded' });
|
||||
const cnt = await page.locator(`html[data-theme="${t}"]`).count();
|
||||
if (cnt > 0) { pass++; console.log(` ✅ theme cookie applied: ${t}`); }
|
||||
else { fail++; failures.push('theme ' + t); console.log(` ❌ theme ${t}`); }
|
||||
} catch (e) { fail++; console.log(` ❌ theme ${t} ${e.message}`); }
|
||||
}
|
||||
|
||||
// Switch to user
|
||||
await ctx.clearCookies();
|
||||
await page.goto(REACT + '/login');
|
||||
await page.fill('input[name="loginId"]', 'testlogin');
|
||||
await page.fill('input[name="password"]', 'test1234');
|
||||
await Promise.all([page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 12000 }).catch(() => {}), page.click('button[type="submit"]')]);
|
||||
console.log(' ✅ user login → ' + page.url());
|
||||
|
||||
for (const p of USER_PATHS) {
|
||||
try {
|
||||
const resp = await page.goto(REACT + p, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const status = resp?.status() ?? 0;
|
||||
if (status < 200 || status >= 400) { fail++; failures.push(`user ${p} HTTP ${status}`); console.log(` ❌ user ${p} HTTP ${status}`); continue; }
|
||||
const html = await page.content();
|
||||
const hasKr = /[가-힣]/.test(html);
|
||||
if (hasKr && html.length > 1500) { pass++; console.log(` ✅ user ${p}`); }
|
||||
else { fail++; failures.push(`user ${p} no content`); console.log(` ❌ user ${p} (${html.length} bytes)`); }
|
||||
} catch (e) { fail++; console.log(` ❌ user ${p} ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n=== TOTAL: ${pass} passed, ${fail} failed ===`);
|
||||
if (fail > 0) { console.log('Failures:'); for (const f of failures) console.log(' - ' + f); process.exit(1); }
|
||||
})();
|
||||
Reference in New Issue
Block a user