diff --git a/next-app/apps/web/src/app/admin/boards/page.tsx b/next-app/apps/web/src/app/admin/boards/page.tsx index 69612f4..f54d99c 100644 --- a/next-app/apps/web/src/app/admin/boards/page.tsx +++ b/next-app/apps/web/src/app/admin/boards/page.tsx @@ -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` - 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 (
게시판관리

게시판 관리 ({rows.length})

-

제목 즉시 변경 가능. 글/댓글 카운트는 g5_board의 캐시값.

+

제목·접근권한(읽기/쓰기/댓글) 즉시 변경. 더 많은 옵션은 "상세설정" 진입.

+
+
⚠️ 모든 게시판에 일괄 적용:
+ + + + + (0=비회원, 1=정회원, 2~9 일반, 10~12=관리자) +
+
- - - - - - - + + + + + + + {rows.map((r) => ( - - - + + - - - - + + + ))} diff --git a/next-app/apps/web/src/app/api/ui/accent/route.ts b/next-app/apps/web/src/app/api/ui/accent/route.ts new file mode 100644 index 0000000..5cbcf3d --- /dev/null +++ b/next-app/apps/web/src/app/api/ui/accent/route.ts @@ -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(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; +} diff --git a/next-app/apps/web/src/app/layout.tsx b/next-app/apps/web/src/app/layout.tsx index 540861d..a17dbf0 100644 --- a/next-app/apps/web/src/app/layout.tsx +++ b/next-app/apps/web/src/app/layout.tsx @@ -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 = { 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 = { + 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 ( - + - + - {!hideEverything &&
} + {!hideEverything &&
}
{children}
diff --git a/next-app/apps/web/src/components/Chrome/Header.tsx b/next-app/apps/web/src/components/Chrome/Header.tsx index f29863e..901db73 100644 --- a/next-app/apps/web/src/components/Chrome/Header.tsx +++ b/next-app/apps/web/src/components/Chrome/Header.tsx @@ -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 (
- +
); } -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 ( +
+ {ACCENTS.map((a) => ( + + ))} +
+ ); +} + +function UtilityBar({ user, accent }: { user: SiteUser | null; accent?: string }) { const [open, setOpen] = useState(false); return (
- - 북마크 - +
+ + 북마크 + + +
{user ? ( 👤 {user.nick} @@ -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 ( -
+
{sections.map((s, si) => (
{s.title && ( diff --git a/next-app/scripts/verify-playwright.mjs b/next-app/scripts/verify-playwright.mjs new file mode 100644 index 0000000..0301aee --- /dev/null +++ b/next-app/scripts/verify-playwright.mjs @@ -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); } +})();
그룹슬러그제목 (변경 가능)댓글비밀바로가기그룹슬러그제목접근권한 Lv (읽기 / 쓰기 / 댓글 / 적용)댓글관리
{r.gr_id}{r.bo_table} + {r.gr_id}{r.bo_table}
- - + +
{r.bo_count_write?.toLocaleString() ?? 0}{r.bo_count_comment?.toLocaleString() ?? 0}{r.bo_use_secret ? '✓' : '–'} - 상세설정 - /{r.bo_table} + +
+ + + / + + / + + +
+
{r.bo_count_write?.toLocaleString() ?? 0}{r.bo_count_comment?.toLocaleString() ?? 0} + 상세 +