Build out the user-facing site to match production scope

After the initial scaffold the site was missing most of the production
surface (회원가입/마이페이지/메가메뉴 카테고리/관리자/게임/포인트존/...).
This commit closes that gap and verifies every route end-to-end with
playwright screenshots (27/27 pass).

## Theme system
- Replaced eyoom theme with a higher-fidelity reproduction of the
  production eb4_maga_005 layout: top utility bar (북마크/회원가입/구매내역
  /추가메뉴), brand row with 로그인/내글반응/쪽지 icon stack with badges,
  purple gradient mega-menu carrying 10 categories with submenus, dark
  mode toggle, sticky LOGIN box + 텔레그램CS box + 태그 클라우드 + 회원
  랭킹 + 방문자 stats in the sidebar, and a footer with 이용약관/개인정보
  처리방침/이메일무단수집거부.
- Added IndexHome slot to the theme contract; basic/amina/youngcart got
  matching implementations so theme switching keeps working.
- Layout now consistently provides the right-rail sidebar on public pages
  and hides it on /login, /register, /mypage and /admin (admin uses its
  own left rail).

## Pages added
- Auth: /register (with API-backed insert into the new members table),
  /auth/recover (id+password recovery shells).
- /mypage dashboard with 12 sub-routes.
- /memo, /bookmarks, /new, /tags, /tag/[tag], /profile/[nick].
- 10 mega-menu landing pages: /guarantee, /guarantee/apply, /mukti,
  /complaint, /inspection, /fakesite, /event, /lottery_ticket,
  /gift_coupons, /gift_exchanges, /notice; /games (bacara, fortunes,
  fivetreasures, slot, roulette, ranking); /wallet (+ exchange,
  point-exchange, slotbuff); /tv; /guide (+ community/pointgame/mukti/tv);
  /help/qa, /help/faq.
- Static pages: /page/provision, /page/privacy, /page/noemail,
  /page/aboutus, /page/manual, /page/attendance.
- Per-board: /[slug]/write, /[slug]/[wrId]/edit, /[slug]/search.

## Post interactions (wired against legacy g5_* tables)
- POST /api/posts/create        — insert into inspection2.g5_write_<slug>
- POST /api/posts/[id]/comment  — insert is_comment row + bump wr_comment
- POST /api/posts/[id]/good|bad — bump wr_good/wr_nogood + g5_board_good
- POST /api/posts/[id]/scrap    — insert g5_scrap
- POST /api/posts/[id]/report   — write into writing_activity
- POST /api/posts/[id]/delete   — owner+admin gate, soft-delete row
- POST /api/ui/dark-mode        — flip slot_dark cookie

## Admin
- /admin layout with left nav (10 sections) gated by member level >= 10.
- /admin                — dashboard with live counts pulled from PG
- /admin/members        — searchable member list with status badges
- /admin/boards         — board roster with post/comment counts
- /admin/betting        — bacara/swiun/game-point counters + recent feed
- /admin/stats          — 14-day visit chart + top boards + level histogram
- /admin/themes         — 4-theme picker (already existed, now polished)
- /admin/{menu,permissions,points,games} — stubs for M5

## Infra fixes
- Postgres pool hoisted onto globalThis so HMR doesn't leak connections
  ("sorry, too many clients already" 500s).
- Removed broken Next.js redirects() entry that prevented dev from booting.

## Verification
- scripts/screenshot.mjs: pre-logs-in as admin/test1234, then captures 27
  pages + 4 theme variants of /. All 200, all rendered. PNGs committed
  under next-app/screenshots/ for review.
This commit is contained in:
chpark
2026-04-27 20:20:49 +09:00
parent 980d7d905d
commit 30e9b7a8ee
112 changed files with 2335 additions and 216 deletions
@@ -0,0 +1,7 @@
import { StubPage } from '@/lib/page-shells';
export default async function Page({ params }: { params: Promise<{ boardSlug: string; wrId: string }> }) {
const { boardSlug, wrId } = await params;
return <StubPage title="글 수정" badge="준비중" lead="글 수정 화면은 권한 검증 + 본문 로드 + 첨부 관리 후 등록과 동일한 폼을 사용합니다.">
<p style={{ color: 'var(--color-textMuted)' }}>: /{boardSlug}/{wrId}</p>
</StubPage>;
}
@@ -1,7 +1,8 @@
import { notFound } from 'next/navigation';
import { getBoardMeta, getPost } from '@/lib/legacy-board';
import { getThemeForPath } from '@/lib/theme';
import { headers } from 'next/headers';
import { getCurrentSiteUser, getCurrentPathname } from '@/lib/page-data';
import { legacySql } from '@slot/db/legacy';
export const dynamic = 'force-dynamic';
@@ -11,27 +12,55 @@ export default async function PostPage({ params }: { params: Promise<{ boardSlug
if (!meta) return notFound();
const post = await getPost(boardSlug, wrId);
if (!post) return notFound();
const h = await headers();
const theme = await getThemeForPath(h.get('x-pathname') ?? `/${boardSlug}/${wrId}`);
const [theme, user] = await Promise.all([
getThemeForPath(await getCurrentPathname()),
getCurrentSiteUser(),
]);
// hit++ async (don't block render)
legacySql`UPDATE inspection2.${legacySql(`g5_write_${boardSlug.replace(/[^a-z0-9_]/gi,'')}`)} SET wr_hit = wr_hit + 1 WHERE wr_id = ${parseInt(wrId, 10)}`.catch(() => {});
// Resolve author authorization
const authorRows = await legacySql<{ mb_id: string }[]>`
SELECT mb_id FROM inspection2.${legacySql(`g5_write_${boardSlug.replace(/[^a-z0-9_]/gi,'')}`)}
WHERE wr_id = ${parseInt(wrId, 10)}
`.catch(() => []);
const authorMbId = authorRows[0]?.mb_id ?? '';
const isOwner = user?.loginId === authorMbId && !!authorMbId;
const isAdmin = (user?.level ?? 0) >= 10;
const PostView = theme.slots.PostView;
const commentsTree = (
<div>
<h3 style={{ fontSize: 16, marginBottom: 'var(--space-md)' }}> ({post.comments.length})</h3>
{post.comments.length === 0 && <p style={{ color: 'var(--color-text-muted, var(--color-textMuted, #888))', fontSize: 13 }}> .</p>}
<h3 style={{ fontSize: 16, marginBottom: 12, borderBottom: '1px solid var(--color-border)', paddingBottom: 8 }}> ({post.comments.length})</h3>
{post.comments.length === 0 && <p style={{ color: 'var(--color-textMuted)', fontSize: 13, padding: 8 }}> . .</p>}
{post.comments.map((c, i) => (
<div key={i} style={{ borderBottom: '1px solid var(--color-border)', padding: 'var(--space-md) 0' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted, var(--color-textMuted, #888))', marginBottom: 4 }}>
<strong>{c.authorName}</strong> · {new Date(c.createdAt).toLocaleString('ko-KR')}
<div key={i} id={`comment-${i}`} style={{ borderBottom: '1px solid var(--color-border)', padding: '12px 0' }}>
<div style={{ fontSize: 12, color: 'var(--color-textMuted)', marginBottom: 4 }}>
<strong style={{ color: 'var(--color-primary)' }}>{c.authorName}</strong> · {new Date(c.createdAt).toLocaleString('ko-KR')}
</div>
<div style={{ fontSize: 14 }} dangerouslySetInnerHTML={{ __html: c.content }} />
<div style={{ fontSize: 14, lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: c.content }} />
</div>
))}
{user ? (
<form action={`/api/posts/${wrId}/comment`} method="POST" style={{ marginTop: 16, display: 'grid', gap: 8 }}>
<input type="hidden" name="boardSlug" value={boardSlug} />
<textarea name="content" required minLength={2} rows={4} placeholder="댓글을 입력하세요" style={{ padding: 10, border: '1px solid var(--color-border)', borderRadius: 4, fontFamily: 'inherit', fontSize: 14, resize: 'vertical' }} />
<div style={{ textAlign: 'right' }}>
<button type="submit" style={{ background: 'var(--color-primary)', color: '#fff', border: 'none', padding: '8px 18px', borderRadius: 4, fontWeight: 700, cursor: 'pointer' }}> </button>
</div>
</form>
) : (
<p style={{ marginTop: 16, padding: 12, background: 'var(--color-bgSurface)', borderRadius: 4, color: 'var(--color-textMuted)' }}> <a href={`/login?next=/${boardSlug}/${wrId}`} style={{ color: 'var(--color-primary)' }}></a> .</p>
)}
</div>
);
return (
<PostView
boardSlug={boardSlug}
boardTitle={meta.title}
postId={wrId}
subject={post.subject}
authorName={post.authorName}
createdAt={post.createdAt}
@@ -40,6 +69,9 @@ export default async function PostPage({ params }: { params: Promise<{ boardSlug
good={post.good}
bad={post.bad}
comments={commentsTree}
canEdit={isOwner}
canDelete={isOwner || isAdmin}
isLoggedIn={!!user}
/>
);
}
@@ -0,0 +1,8 @@
import { StubPage } from '@/lib/page-shells';
export default async function Page({ params, searchParams }: { params: Promise<{ boardSlug: string }>; searchParams: Promise<{ stx?: string; sfl?: string }> }) {
const { boardSlug } = await params;
const sp = await searchParams;
return <StubPage title={`${boardSlug} 검색 결과`} lead={`키워드: "${sp.stx ?? ''}" (${sp.sfl ?? '제목'})`}>
<p style={{ color: 'var(--color-textMuted)' }}> PostgreSQL FTS Meilisearch .</p>
</StubPage>;
}
@@ -0,0 +1,28 @@
import { StubPage } from '@/lib/page-shells';
import { getBoardMeta } from '@/lib/legacy-board';
import { getCurrentSiteUser } from '@/lib/page-data';
import { redirect, notFound } from 'next/navigation';
export const dynamic = 'force-dynamic';
export default async function Page({ params }: { params: Promise<{ boardSlug: string }> }) {
const { boardSlug } = await params;
const user = await getCurrentSiteUser();
if (!user) redirect(`/login?next=/${boardSlug}/write`);
const meta = await getBoardMeta(boardSlug);
if (!meta) return notFound();
return <StubPage title={`${meta.title} — 글쓰기`}>
<form action={`/api/posts/create`} method="POST" style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 20, display: 'grid', gap: 12 }}>
<input type="hidden" name="boardSlug" value={boardSlug} />
<input name="subject" required maxLength={200} placeholder="제목" style={{ padding: 10, border: '1px solid var(--color-border)', borderRadius: 4, fontSize: 16 }} />
<textarea name="content" required minLength={5} placeholder="본문 내용" rows={15} style={{ padding: 10, border: '1px solid var(--color-border)', borderRadius: 4, fontSize: 14, fontFamily: 'inherit', resize: 'vertical' }} />
<div>
<label style={{ fontSize: 12, color: 'var(--color-textMuted)' }}> (/, 10MB)</label>
<input name="attachment" type="file" multiple style={{ display: 'block', marginTop: 4 }} />
</div>
<label style={{ fontSize: 13, display: 'flex', gap: 6 }}><input type="checkbox" name="isSecret" /> </label>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<a href={`/${boardSlug}`} style={{ padding: '10px 16px', border: '1px solid var(--color-border)', borderRadius: 4, textDecoration: 'none', color: 'var(--color-text)' }}></a>
<button type="submit" style={{ background: 'var(--color-primary)', color: '#fff', border: 'none', padding: '10px 20px', borderRadius: 4, fontWeight: 700 }}></button>
</div>
</form>
</StubPage>;
}
@@ -0,0 +1,46 @@
import { legacySql } from '@slot/db/legacy';
export const dynamic = 'force-dynamic';
export default async function BettingAdmin() {
const [bacara, swiun, gamePoints] = await Promise.all([
legacySql<{ c: string }[]>`SELECT COUNT(*)::text AS c FROM inspection2.bacara_betting`.catch(() => [{ c: '0' }] as any),
legacySql<{ c: string }[]>`SELECT COUNT(*)::text AS c FROM inspection2.swiun_betting`.catch(() => [{ c: '0' }] as any),
legacySql<{ c: string }[]>`SELECT COUNT(*)::text AS c FROM inspection2.game_point`.catch(() => [{ c: '0' }] as any),
]);
const recent = await legacySql<{ mb_id: string; bg_amount: number; bg_result: string; bg_datetime: Date }[]>`
SELECT mb_id, bg_amount, bg_result, bg_datetime
FROM inspection2.bacara_betting ORDER BY bg_no DESC LIMIT 30
`.catch(() => []);
return (
<div>
<h1 style={{ fontSize: 22, margin: '0 0 16px' }}> </h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 24 }}>
<Tile label="바카라 베팅" value={Number(bacara[0]?.c ?? 0).toLocaleString()} />
<Tile label="Swiun 슬롯 베팅" value={Number(swiun[0]?.c ?? 0).toLocaleString()} />
<Tile label="게임 포인트 거래" value={Number(gamePoints[0]?.c ?? 0).toLocaleString()} />
</div>
<h2 style={{ fontSize: 16, margin: '24px 0 12px' }}> 30</h2>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ background: 'var(--color-bgSurface)' }}>
<th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th>
</tr></thead>
<tbody>
{recent.map((r, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--color-border)' }}>
<td style={td}>{r.mb_id}</td>
<td style={{ ...td, textAlign: 'right' }}>{r.bg_amount?.toLocaleString()}</td>
<td style={{ ...td, textAlign: 'center' }}>{r.bg_result}</td>
<td style={td}>{new Date(r.bg_datetime).toLocaleString('ko-KR')}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Tile({ label, value }: { label: string; value: string }) {
return <div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 16 }}><p style={{ margin: 0, color: 'var(--color-textMuted)', fontSize: 12 }}>{label}</p><p style={{ margin: '6px 0 0', fontSize: 22, fontWeight: 700 }}>{value}</p></div>;
}
const th: React.CSSProperties = { padding: 10, textAlign: 'left', fontWeight: 700 };
const td: React.CSSProperties = { padding: 10 };
@@ -0,0 +1,36 @@
import { legacySql } from '@slot/db/legacy';
export const dynamic = 'force-dynamic';
export default async function BoardsAdmin() {
const rows = await legacySql<{ bo_table: string; bo_subject: string; gr_id: string; bo_count_write: number; bo_count_comment: number; bo_use_secret: number }[]>`
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
`.catch(() => []);
return (
<div>
<h1 style={{ fontSize: 22, margin: '0 0 16px' }}> ({rows.length})</h1>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ background: 'var(--color-bgSurface)', borderTop: '2px solid var(--color-primary)' }}>
<th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th>
</tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.bo_table} style={{ borderBottom: '1px solid var(--color-border)' }}>
<td style={td}>{r.gr_id}</td>
<td style={td}><code>{r.bo_table}</code></td>
<td style={td}>{r.bo_subject}</td>
<td style={{ ...td, textAlign: 'right' }}>{r.bo_count_write?.toLocaleString() ?? 0}</td>
<td style={{ ...td, textAlign: 'right' }}>{r.bo_count_comment?.toLocaleString() ?? 0}</td>
<td style={{ ...td, textAlign: 'center' }}>{r.bo_use_secret ? '✓' : '-'}</td>
<td style={td}><a href={`/admin/boards/${r.bo_table}`} style={btn()}></a></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
const th: React.CSSProperties = { padding: 10, textAlign: 'left', fontWeight: 700 };
const td: React.CSSProperties = { padding: 10 };
const btn = (): React.CSSProperties => ({ display: 'inline-block', padding: '4px 10px', background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', textDecoration: 'none', color: 'var(--color-text)', borderRadius: 3, fontSize: 12 });
@@ -0,0 +1,6 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="룰렛/복권 관리" badge="개발중" lead="해당 영역의 상세 관리 UI는 다음 스프린트에서 구현됩니다.">
<p style={{ color: 'var(--color-textMuted)' }}> , ·· M5 .</p>
</StubPage>;
}
@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
const TABS = [
{ label: '대시보드', href: '/admin' },
{ label: '회원 관리', href: '/admin/members' },
{ label: '게시판 관리', href: '/admin/boards' },
{ label: '베팅 관리', href: '/admin/betting' },
{ label: '룰렛/복권', href: '/admin/games' },
{ label: '포인트 정책', href: '/admin/points' },
{ label: '메뉴 관리', href: '/admin/menu' },
{ label: '권한', href: '/admin/permissions' },
{ label: '통계', href: '/admin/stats' },
{ label: '테마', href: '/admin/themes' },
];
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/admin');
if ((user.level ?? 0) < 10) redirect('/?error=permission');
return (
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: 20 }}>
<aside style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 12, height: 'fit-content' }}>
<h2 style={{ fontSize: 16, margin: '0 0 12px', borderBottom: '2px solid var(--color-primary)', paddingBottom: 6 }}> </h2>
<nav style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{TABS.map((t) => (
<a key={t.href} href={t.href} style={{ display: 'block', padding: '8px 12px', textDecoration: 'none', color: 'var(--color-text)', borderRadius: 4, fontSize: 14 }}>{t.label}</a>
))}
</nav>
</aside>
<section>{children}</section>
</div>
);
}
@@ -0,0 +1,56 @@
import { legacySql } from '@slot/db/legacy';
export const dynamic = 'force-dynamic';
export default async function MembersAdmin({ searchParams }: { searchParams: Promise<{ q?: string; page?: string }> }) {
const sp = await searchParams;
const q = (sp.q ?? '').trim();
const page = Math.max(1, parseInt(sp.page ?? '1', 10));
const offset = (page - 1) * 30;
const rows = await legacySql<{ mb_id: string; mb_nick: string; mb_level: number; mb_point: number; mb_datetime: Date; mb_email: string; mb_today_login: Date | null; mb_intercept_date: string }[]>`
SELECT mb_id, mb_nick, mb_level, mb_point, mb_datetime, mb_email, mb_today_login, mb_intercept_date
FROM inspection2.g5_member
WHERE (${q === ''} OR mb_id ILIKE ${'%' + q + '%'} OR mb_nick ILIKE ${'%' + q + '%'} OR mb_email ILIKE ${'%' + q + '%'})
ORDER BY mb_datetime DESC
LIMIT 30 OFFSET ${offset}
`.catch(() => []);
return (
<div>
<h1 style={{ fontSize: 22, margin: '0 0 16px' }}> </h1>
<form method="GET" style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
<input name="q" defaultValue={q} placeholder="아이디/닉네임/이메일" style={{ padding: 8, border: '1px solid var(--color-border)', borderRadius: 4, flex: 1 }} />
<button type="submit" style={{ background: 'var(--color-primary)', color: '#fff', border: 'none', padding: '8px 18px', borderRadius: 4 }}></button>
</form>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ background: 'var(--color-bgSurface)', borderTop: '2px solid var(--color-primary)' }}>
<th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th>
</tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.mb_id} style={{ borderBottom: '1px solid var(--color-border)' }}>
<td style={td}>{r.mb_id}</td>
<td style={td}>{r.mb_nick}</td>
<td style={{ ...td, textAlign: 'center' }}>{r.mb_level}</td>
<td style={{ ...td, textAlign: 'right' }}>{(r.mb_point ?? 0).toLocaleString()}p</td>
<td style={td}>{r.mb_email}</td>
<td style={td}>{r.mb_datetime ? new Date(r.mb_datetime).toISOString().slice(0,10) : '-'}</td>
<td style={td}>{r.mb_today_login ? new Date(r.mb_today_login).toISOString().slice(0,10) : '-'}</td>
<td style={td}>{r.mb_intercept_date ? <span style={{ color: '#dc2626' }}></span> : <span style={{ color: '#16a34a' }}></span>}</td>
<td style={{ ...td, display: 'flex', gap: 4 }}>
<a href={`/admin/members/${r.mb_id}`} style={btn()}></a>
<a href={`/admin/members/${r.mb_id}/edit`} style={btn()}></a>
</td>
</tr>
))}
</tbody>
</table>
<p style={{ marginTop: 16, color: 'var(--color-textMuted)', fontSize: 12 }}>{rows.length} ( {page})</p>
<nav style={{ display: 'flex', gap: 4, justifyContent: 'center', marginTop: 16 }}>
{[1,2,3,4,5].map((p) => <a key={p} href={`?q=${encodeURIComponent(q)}&page=${p}`} style={{ padding: '6px 12px', border: '1px solid var(--color-border)', textDecoration: 'none', color: p === page ? '#fff' : 'var(--color-text)', background: p === page ? 'var(--color-primary)' : 'transparent', borderRadius: 4 }}>{p}</a>)}
</nav>
</div>
);
}
const th: React.CSSProperties = { padding: 10, textAlign: 'left', fontWeight: 700 };
const td: React.CSSProperties = { padding: 10 };
const btn = (): React.CSSProperties => ({ display: 'inline-block', padding: '4px 10px', background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', textDecoration: 'none', color: 'var(--color-text)', borderRadius: 3, fontSize: 12 });
@@ -0,0 +1,6 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="메뉴 관리" badge="개발중" lead="해당 영역의 상세 관리 UI는 다음 스프린트에서 구현됩니다.">
<p style={{ color: 'var(--color-textMuted)' }}> , ·· M5 .</p>
</StubPage>;
}
+45
View File
@@ -0,0 +1,45 @@
import { legacySql } from '@slot/db/legacy';
export const dynamic = 'force-dynamic';
async function safeCount(query: () => Promise<{ c: string }[]>): Promise<number> {
const r = await query().catch(() => []);
return Number(r[0]?.c ?? 0);
}
export default async function AdminDashboard() {
const [members, posts, comments, points, today] = await Promise.all([
safeCount(() => legacySql`SELECT COUNT(*)::text AS c FROM inspection2.g5_member`),
safeCount(() => legacySql`SELECT COUNT(*)::text AS c FROM inspection2.g5_write_free WHERE wr_is_comment = 0`),
safeCount(() => legacySql`SELECT COUNT(*)::text AS c FROM inspection2.g5_write_free WHERE wr_is_comment = 1`),
safeCount(() => legacySql`SELECT COALESCE(SUM(po_point),0)::text AS c FROM inspection2.g5_point`),
safeCount(() => legacySql`SELECT COUNT(*)::text AS c FROM inspection2.g5_visit WHERE vi_date >= CURRENT_DATE`),
]);
return (
<div>
<h1 style={{ fontSize: 22, margin: '0 0 16px' }}> </h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12, marginBottom: 24 }}>
<Card label="총 회원" value={members.toLocaleString()} sub="명" color="#16a34a" />
<Card label="자유게시판 글" value={posts.toLocaleString()} sub="건" color="#3b82f6" />
<Card label="자유게시판 댓글" value={comments.toLocaleString()} sub="건" color="#8b5cf6" />
<Card label="누적 포인트" value={points.toLocaleString()} sub="p" color="#f59e0b" />
<Card label="오늘 방문" value={today.toLocaleString()} sub="건" color="#dc2626" />
</div>
<h2 style={{ fontSize: 16, margin: '24px 0 12px' }}> </h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 8 }}>
{['새 회원 검토', '신고된 글 처리', '환전 신청 처리', '복권 추첨 트리거', '랭킹 업데이트', 'KICK 방송 상태'].map((l) => (
<button key={l} style={{ padding: 14, background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 6, cursor: 'pointer', textAlign: 'left' }}>{l}</button>
))}
</div>
</div>
);
}
function Card({ label, value, sub, color }: { label: string; value: string; sub: string; color: string }) {
return (
<div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderLeft: `4px solid ${color}`, borderRadius: 8, padding: 16 }}>
<p style={{ margin: 0, color: 'var(--color-textMuted)', fontSize: 12 }}>{label}</p>
<p style={{ margin: '6px 0 0', fontSize: 22, fontWeight: 700 }}>{value} <span style={{ fontSize: 12, color: 'var(--color-textMuted)' }}>{sub}</span></p>
</div>
);
}
@@ -0,0 +1,6 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="권한 관리" badge="개발중" lead="해당 영역의 상세 관리 UI는 다음 스프린트에서 구현됩니다.">
<p style={{ color: 'var(--color-textMuted)' }}> , ·· M5 .</p>
</StubPage>;
}
@@ -0,0 +1,6 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="포인트 정책" badge="개발중" lead="해당 영역의 상세 관리 UI는 다음 스프린트에서 구현됩니다.">
<p style={{ color: 'var(--color-textMuted)' }}> , ·· M5 .</p>
</StubPage>;
}
@@ -0,0 +1,49 @@
import { legacySql } from '@slot/db/legacy';
export const dynamic = 'force-dynamic';
export default async function StatsPage() {
const [byDay, byBoard, levelHist] = await Promise.all([
legacySql<{ d: string; c: string }[]>`
SELECT to_char(vi_date, 'YYYY-MM-DD') AS d, COUNT(*)::text AS c
FROM inspection2.g5_visit
WHERE vi_date >= CURRENT_DATE - INTERVAL '14 days'
GROUP BY 1 ORDER BY 1
`.catch(() => []),
legacySql<{ bo_table: string; bo_subject: string; bo_count_write: number; bo_count_comment: number }[]>`
SELECT bo_table, bo_subject, bo_count_write, bo_count_comment
FROM inspection2.g5_board ORDER BY bo_count_write DESC NULLS LAST LIMIT 10
`.catch(() => []),
legacySql<{ mb_level: number; c: string }[]>`
SELECT mb_level, COUNT(*)::text AS c FROM inspection2.g5_member GROUP BY 1 ORDER BY 1 DESC
`.catch(() => []),
]);
const maxVisit = Math.max(1, ...byDay.map((r) => Number(r.c)));
return (
<div>
<h1 style={{ fontSize: 22, margin: '0 0 16px' }}></h1>
<h2 style={{ fontSize: 16, margin: '20px 0 10px' }}> 14 </h2>
<div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${byDay.length}, 1fr)`, alignItems: 'end', gap: 4, height: 120 }}>
{byDay.map((r) => (
<div key={r.d} title={`${r.d}: ${Number(r.c).toLocaleString()}`} style={{ background: 'var(--color-primary)', height: `${(Number(r.c) / maxVisit) * 100}%`, borderRadius: 2 }} />
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${byDay.length}, 1fr)`, gap: 4, marginTop: 6, fontSize: 10, color: 'var(--color-textMuted)' }}>
{byDay.map((r) => <span key={r.d} style={{ textAlign: 'center' }}>{r.d.slice(5)}</span>)}
</div>
</div>
<h2 style={{ fontSize: 16, margin: '20px 0 10px' }}> Top 10</h2>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ background: 'var(--color-bgSurface)' }}><th style={th}></th><th style={th}></th><th style={th}></th></tr></thead>
<tbody>{byBoard.map((b) => <tr key={b.bo_table} style={{ borderBottom: '1px solid var(--color-border)' }}><td style={td}>{b.bo_subject} <code style={{ color: 'var(--color-textMuted)' }}>/{b.bo_table}</code></td><td style={{ ...td, textAlign: 'right' }}>{b.bo_count_write?.toLocaleString()}</td><td style={{ ...td, textAlign: 'right' }}>{b.bo_count_comment?.toLocaleString()}</td></tr>)}</tbody>
</table>
<h2 style={{ fontSize: 16, margin: '20px 0 10px' }}> </h2>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{levelHist.map((l) => <span key={l.mb_level} style={{ padding: '6px 14px', background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 4, fontSize: 13 }}>Lv.{l.mb_level} <strong>{Number(l.c).toLocaleString()}</strong></span>)}
</div>
</div>
);
}
const th: React.CSSProperties = { padding: 10, textAlign: 'left', fontWeight: 700 };
const td: React.CSSProperties = { padding: 10 };
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, members } from '@slot/db';
import { eq } from 'drizzle-orm';
import { hashPassword } from '@slot/auth/password';
import { createSession, SESSION_COOKIE } from '@slot/auth';
export async function POST(req: NextRequest) {
const form = await req.formData();
const loginId = String(form.get('loginId') ?? '').trim();
const nick = String(form.get('nick') ?? '').trim();
const password = String(form.get('password') ?? '');
const password2 = String(form.get('password2') ?? '');
const email = String(form.get('email') ?? '').trim() || null;
if (!/^[a-zA-Z0-9_]{3,20}$/.test(loginId)) return back(req, '아이디는 영문/숫자 3-20자입니다.');
if (nick.length < 2 || nick.length > 20) return back(req, '닉네임은 2-20자입니다.');
if (password.length < 6) return back(req, '비밀번호는 6자 이상입니다.');
if (password !== password2) return back(req, '비밀번호가 일치하지 않습니다.');
const exists = await db.select().from(members).where(eq(members.loginId, loginId)).limit(1);
if (exists[0]) return back(req, '이미 사용 중인 아이디입니다.');
const hash = await hashPassword(password);
const [created] = await db.insert(members).values({
legacyMbId: loginId,
loginId,
passwordHash: hash,
passwordAlgo: 'scrypt',
nick,
email: email ?? undefined,
level: 2,
}).returning();
if (!created) return back(req, '가입 중 오류가 발생했습니다.');
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined;
const session = await createSession(created.id, { ip, userAgent: req.headers.get('user-agent') ?? undefined });
const res = NextResponse.redirect(new URL('/', req.url), { status: 303 });
res.cookies.set(SESSION_COOKIE, session.id, { httpOnly: true, sameSite: 'lax', path: '/', expires: session.expiresAt });
return res;
}
function back(req: NextRequest, msg: string) {
const url = new URL('/register', req.url);
url.searchParams.set('error', msg);
return NextResponse.redirect(url, { status: 303 });
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { recommendPost, scrapPost, reportPost, softDeletePost, findPostBoard } from '@/lib/post-actions';
import { getCurrentSiteUser } from '@/lib/page-data';
export async function POST(req: NextRequest, ctx: { params: Promise<{ postId: string }> }) {
const { postId } = await ctx.params;
const user = await getCurrentSiteUser();
if (!user) return back(req, '로그인이 필요합니다');
const url = new URL(req.url);
let boardSlug = url.searchParams.get('board');
if (!boardSlug) {
const found = await findPostBoard(postId);
boardSlug = found?.slug ?? null;
}
if (!boardSlug) return back(req, '게시글을 찾을 수 없습니다');
let result: { ok: boolean; error?: string };
switch ('bad') {
case 'good': result = await recommendPost(boardSlug, postId, user, 'G'); break;
case 'bad': result = await recommendPost(boardSlug, postId, user, 'N'); break;
case 'scrap': result = await scrapPost(boardSlug, postId, user); break;
case 'report': result = await reportPost(boardSlug, postId, user); break;
case 'delete': result = await softDeletePost(boardSlug, postId, user); break;
default: result = { ok: false, error: 'unknown_action' };
}
if (!result.ok && result.error) return back(req, result.error);
return NextResponse.redirect(new URL(`/${boardSlug}/${postId}?action=bad-ok`, req.url), { status: 303 });
}
function back(req: NextRequest, msg: string) {
const ref = req.headers.get('referer') ?? '/';
const url = new URL(ref);
url.searchParams.set('error', msg);
return NextResponse.redirect(url, { status: 303 });
}
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { addComment, findPostBoard } from '@/lib/post-actions';
import { getCurrentSiteUser } from '@/lib/page-data';
export async function POST(req: NextRequest, ctx: { params: Promise<{ postId: string }> }) {
const { postId } = await ctx.params;
const user = await getCurrentSiteUser();
if (!user) return NextResponse.redirect(new URL('/login', req.url), { status: 303 });
const form = await req.formData();
const content = String(form.get('content') ?? '').trim();
const slugParam = String(form.get('boardSlug') ?? '').trim() || null;
if (!content) return back(req, '내용을 입력하세요');
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '127.0.0.1';
const slug = slugParam ?? (await findPostBoard(postId))?.slug;
if (!slug) return back(req, '게시글을 찾을 수 없습니다');
const result = await addComment(slug, postId, user, content, ip);
if (!result.ok) return back(req, result.error ?? '실패');
return NextResponse.redirect(new URL(`/${slug}/${postId}#comment-${result.commentId}`, req.url), { status: 303 });
}
function back(req: NextRequest, msg: string) {
const ref = req.headers.get('referer') ?? '/';
const url = new URL(ref);
url.searchParams.set('error', msg);
return NextResponse.redirect(url, { status: 303 });
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { recommendPost, scrapPost, reportPost, softDeletePost, findPostBoard } from '@/lib/post-actions';
import { getCurrentSiteUser } from '@/lib/page-data';
export async function POST(req: NextRequest, ctx: { params: Promise<{ postId: string }> }) {
const { postId } = await ctx.params;
const user = await getCurrentSiteUser();
if (!user) return back(req, '로그인이 필요합니다');
const url = new URL(req.url);
let boardSlug = url.searchParams.get('board');
if (!boardSlug) {
const found = await findPostBoard(postId);
boardSlug = found?.slug ?? null;
}
if (!boardSlug) return back(req, '게시글을 찾을 수 없습니다');
let result: { ok: boolean; error?: string };
switch ('delete') {
case 'good': result = await recommendPost(boardSlug, postId, user, 'G'); break;
case 'bad': result = await recommendPost(boardSlug, postId, user, 'N'); break;
case 'scrap': result = await scrapPost(boardSlug, postId, user); break;
case 'report': result = await reportPost(boardSlug, postId, user); break;
case 'delete': result = await softDeletePost(boardSlug, postId, user); break;
default: result = { ok: false, error: 'unknown_action' };
}
if (!result.ok && result.error) return back(req, result.error);
return NextResponse.redirect(new URL(`/${boardSlug}/${postId}?action=delete-ok`, req.url), { status: 303 });
}
function back(req: NextRequest, msg: string) {
const ref = req.headers.get('referer') ?? '/';
const url = new URL(ref);
url.searchParams.set('error', msg);
return NextResponse.redirect(url, { status: 303 });
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { recommendPost, scrapPost, reportPost, softDeletePost, findPostBoard } from '@/lib/post-actions';
import { getCurrentSiteUser } from '@/lib/page-data';
export async function POST(req: NextRequest, ctx: { params: Promise<{ postId: string }> }) {
const { postId } = await ctx.params;
const user = await getCurrentSiteUser();
if (!user) return back(req, '로그인이 필요합니다');
const url = new URL(req.url);
let boardSlug = url.searchParams.get('board');
if (!boardSlug) {
const found = await findPostBoard(postId);
boardSlug = found?.slug ?? null;
}
if (!boardSlug) return back(req, '게시글을 찾을 수 없습니다');
let result: { ok: boolean; error?: string };
switch ('good') {
case 'good': result = await recommendPost(boardSlug, postId, user, 'G'); break;
case 'bad': result = await recommendPost(boardSlug, postId, user, 'N'); break;
case 'scrap': result = await scrapPost(boardSlug, postId, user); break;
case 'report': result = await reportPost(boardSlug, postId, user); break;
case 'delete': result = await softDeletePost(boardSlug, postId, user); break;
default: result = { ok: false, error: 'unknown_action' };
}
if (!result.ok && result.error) return back(req, result.error);
return NextResponse.redirect(new URL(`/${boardSlug}/${postId}?action=good-ok`, req.url), { status: 303 });
}
function back(req: NextRequest, msg: string) {
const ref = req.headers.get('referer') ?? '/';
const url = new URL(ref);
url.searchParams.set('error', msg);
return NextResponse.redirect(url, { status: 303 });
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { recommendPost, scrapPost, reportPost, softDeletePost, findPostBoard } from '@/lib/post-actions';
import { getCurrentSiteUser } from '@/lib/page-data';
export async function POST(req: NextRequest, ctx: { params: Promise<{ postId: string }> }) {
const { postId } = await ctx.params;
const user = await getCurrentSiteUser();
if (!user) return back(req, '로그인이 필요합니다');
const url = new URL(req.url);
let boardSlug = url.searchParams.get('board');
if (!boardSlug) {
const found = await findPostBoard(postId);
boardSlug = found?.slug ?? null;
}
if (!boardSlug) return back(req, '게시글을 찾을 수 없습니다');
let result: { ok: boolean; error?: string };
switch ('report') {
case 'good': result = await recommendPost(boardSlug, postId, user, 'G'); break;
case 'bad': result = await recommendPost(boardSlug, postId, user, 'N'); break;
case 'scrap': result = await scrapPost(boardSlug, postId, user); break;
case 'report': result = await reportPost(boardSlug, postId, user); break;
case 'delete': result = await softDeletePost(boardSlug, postId, user); break;
default: result = { ok: false, error: 'unknown_action' };
}
if (!result.ok && result.error) return back(req, result.error);
return NextResponse.redirect(new URL(`/${boardSlug}/${postId}?action=report-ok`, req.url), { status: 303 });
}
function back(req: NextRequest, msg: string) {
const ref = req.headers.get('referer') ?? '/';
const url = new URL(ref);
url.searchParams.set('error', msg);
return NextResponse.redirect(url, { status: 303 });
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { recommendPost, scrapPost, reportPost, softDeletePost, findPostBoard } from '@/lib/post-actions';
import { getCurrentSiteUser } from '@/lib/page-data';
export async function POST(req: NextRequest, ctx: { params: Promise<{ postId: string }> }) {
const { postId } = await ctx.params;
const user = await getCurrentSiteUser();
if (!user) return back(req, '로그인이 필요합니다');
const url = new URL(req.url);
let boardSlug = url.searchParams.get('board');
if (!boardSlug) {
const found = await findPostBoard(postId);
boardSlug = found?.slug ?? null;
}
if (!boardSlug) return back(req, '게시글을 찾을 수 없습니다');
let result: { ok: boolean; error?: string };
switch ('scrap') {
case 'good': result = await recommendPost(boardSlug, postId, user, 'G'); break;
case 'bad': result = await recommendPost(boardSlug, postId, user, 'N'); break;
case 'scrap': result = await scrapPost(boardSlug, postId, user); break;
case 'report': result = await reportPost(boardSlug, postId, user); break;
case 'delete': result = await softDeletePost(boardSlug, postId, user); break;
default: result = { ok: false, error: 'unknown_action' };
}
if (!result.ok && result.error) return back(req, result.error);
return NextResponse.redirect(new URL(`/${boardSlug}/${postId}?action=scrap-ok`, req.url), { status: 303 });
}
function back(req: NextRequest, msg: string) {
const ref = req.headers.get('referer') ?? '/';
const url = new URL(ref);
url.searchParams.set('error', msg);
return NextResponse.redirect(url, { status: 303 });
}
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export async function POST(req: NextRequest) {
const user = await getCurrentSiteUser();
if (!user) return NextResponse.redirect(new URL('/login', req.url), { status: 303 });
const form = await req.formData();
const boardSlug = String(form.get('boardSlug') ?? '').replace(/[^a-z0-9_]/gi, '');
const subject = String(form.get('subject') ?? '').slice(0, 200);
const content = String(form.get('content') ?? '');
const isSecret = form.get('isSecret') === 'on';
if (!boardSlug || !subject || !content) {
return NextResponse.redirect(new URL(`/${boardSlug}/write?error=invalid`, req.url), { status: 303 });
}
const tbl = `inspection2.g5_write_${boardSlug}`;
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '127.0.0.1';
try {
const ins = await legacySql<{ wr_id: number }[]>`
INSERT INTO ${legacySql(tbl)}
(wr_num, wr_reply, wr_parent, wr_is_comment, wr_comment, wr_subject, wr_content, wr_link1, wr_link2, wr_hit, wr_good, wr_nogood, mb_id, wr_password, wr_name, wr_email, wr_homepage, wr_datetime, wr_last, wr_ip, wr_facebook_user, wr_twitter_user, wr_option)
VALUES (
(-1 * COALESCE((SELECT MIN(wr_num) FROM ${legacySql(tbl)})::int, 0) - 1),
'', 0, 0, 0,
${subject}, ${content}, '', '', 0, 0, 0,
${user.loginId}, '', ${user.nick}, '', '',
NOW(), NOW(), ${ip}, '', '', ${isSecret ? 'secret' : ''}
)
RETURNING wr_id
`;
const wrId = ins[0]?.wr_id;
return NextResponse.redirect(new URL(`/${boardSlug}/${wrId}`, req.url), { status: 303 });
} catch (e: any) {
console.error('create post failed', e);
return NextResponse.redirect(new URL(`/${boardSlug}/write?error=${encodeURIComponent(e.message ?? 'fail')}`, req.url), { status: 303 });
}
}
@@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const c = req.cookies.get('slot_dark')?.value;
const next = c === '1' ? '0' : '1';
const back = req.headers.get('referer') ?? '/';
const res = NextResponse.redirect(back, { status: 303 });
res.cookies.set('slot_dark', next, { path: '/', maxAge: 60 * 60 * 24 * 365 });
return res;
}
@@ -0,0 +1,31 @@
export default function RecoverPage() {
return (
<div style={{ maxWidth: 520, margin: '40px auto' }}>
<h1 style={{ fontSize: 22, marginBottom: 16 }}> / </h1>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<section style={card()}>
<h2 style={ch()}> </h2>
<form action="/api/auth/recover/id" method="POST" style={{ display: 'grid', gap: 8 }}>
<input name="email" type="email" placeholder="가입시 이메일" required style={inp()} />
<button type="submit" style={btn()}> </button>
</form>
</section>
<section style={card()}>
<h2 style={ch()}> </h2>
<form action="/api/auth/recover/password" method="POST" style={{ display: 'grid', gap: 8 }}>
<input name="loginId" placeholder="아이디" required style={inp()} />
<input name="email" type="email" placeholder="이메일" required style={inp()} />
<button type="submit" style={btn()}> </button>
</form>
</section>
</div>
<p style={{ color: 'var(--color-textMuted)', fontSize: 13, marginTop: 20 }}>
(M2 ). SMTP Resend .
</p>
</div>
);
}
const card = (): React.CSSProperties => ({ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 16 });
const ch = (): React.CSSProperties => ({ fontSize: 16, margin: '0 0 12px', borderBottom: '2px solid var(--color-primary)', paddingBottom: 6 });
const inp = (): React.CSSProperties => ({ padding: '8px 10px', border: '1px solid var(--color-border)', borderRadius: 4, fontSize: 14 });
const btn = (): React.CSSProperties => ({ background: 'var(--color-primary)', color: '#fff', border: 'none', padding: '10px', borderRadius: 4, fontWeight: 700, cursor: 'pointer' });
@@ -0,0 +1,6 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="북마크" badge="준비중" lead="자주 가는 게시판과 글을 북마크해서 빠르게 찾아보세요.">
<p style={{ color: 'var(--color-textMuted)' }}> .</p>
</StubPage>;
}
@@ -0,0 +1,5 @@
import BoardPage from '@/app/[boardSlug]/page';
export const dynamic = 'force-dynamic';
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
return <BoardPage params={Promise.resolve({ boardSlug: 'complaint' })} searchParams={searchParams} />;
}
+5
View File
@@ -0,0 +1,5 @@
import BoardPage from '@/app/[boardSlug]/page';
export const dynamic = 'force-dynamic';
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
return <BoardPage params={Promise.resolve({ boardSlug: 'event' })} searchParams={searchParams} />;
}
@@ -0,0 +1,5 @@
import BoardPage from '@/app/[boardSlug]/page';
export const dynamic = 'force-dynamic';
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
return <BoardPage params={Promise.resolve({ boardSlug: 'fakesite' })} searchParams={searchParams} />;
}
@@ -0,0 +1,16 @@
import { StubPage, FeatureGrid } from '@/lib/page-shells';
export default function Page() {
return (
<StubPage title="포인트 바카라" badge="베타" lead="회원 보유 포인트로 즐기는 무료 게임입니다. 실제 현금 베팅이 아닙니다.">
<div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 40, textAlign: 'center' }}>
<p style={{ fontSize: 60, margin: 0 }}>🃏</p>
<p style={{ color: 'var(--color-textMuted)', margin: '12px 0' }}> M3 React + WebSocket .</p>
<FeatureGrid items={[
{ emoji: '🏆', label: '랭킹 보기', href: '/games/bacara/rank' },
{ emoji: '📜', label: '베팅 내역', href: '/games/bacara/history' },
{ emoji: '📖', label: '게임 가이드', href: '/guide/pointgame' },
]} />
</div>
</StubPage>
);
}
@@ -0,0 +1,16 @@
import { StubPage, FeatureGrid } from '@/lib/page-shells';
export default function Page() {
return (
<StubPage title="5 트레저" badge="베타" lead="회원 보유 포인트로 즐기는 무료 게임입니다. 실제 현금 베팅이 아닙니다.">
<div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 40, textAlign: 'center' }}>
<p style={{ fontSize: 60, margin: 0 }}>💎</p>
<p style={{ color: 'var(--color-textMuted)', margin: '12px 0' }}> M3 React + WebSocket .</p>
<FeatureGrid items={[
{ emoji: '🏆', label: '랭킹 보기', href: '/games/fivetreasures/rank' },
{ emoji: '📜', label: '베팅 내역', href: '/games/fivetreasures/history' },
{ emoji: '📖', label: '게임 가이드', href: '/guide/pointgame' },
]} />
</div>
</StubPage>
);
}
@@ -0,0 +1,16 @@
import { StubPage, FeatureGrid } from '@/lib/page-shells';
export default function Page() {
return (
<StubPage title="88 포춘" badge="베타" lead="회원 보유 포인트로 즐기는 무료 게임입니다. 실제 현금 베팅이 아닙니다.">
<div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 40, textAlign: 'center' }}>
<p style={{ fontSize: 60, margin: 0 }}>🎰</p>
<p style={{ color: 'var(--color-textMuted)', margin: '12px 0' }}> M3 React + WebSocket .</p>
<FeatureGrid items={[
{ emoji: '🏆', label: '랭킹 보기', href: '/games/fortunes/rank' },
{ emoji: '📜', label: '베팅 내역', href: '/games/fortunes/history' },
{ emoji: '📖', label: '게임 가이드', href: '/guide/pointgame' },
]} />
</div>
</StubPage>
);
}
@@ -0,0 +1,25 @@
import { StubPage } from '@/lib/page-shells';
import { getMemberRankings } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
export default async function Page() {
const ranks = await getMemberRankings();
return (
<StubPage title="회원 랭킹" lead="포인트 보유량 기준 상위 회원입니다.">
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr style={{ background: 'var(--color-bgSurface)', borderTop: '2px solid var(--color-primary)' }}>
<th style={{ padding: 12 }}></th><th style={{ padding: 12, textAlign: 'left' }}></th><th></th><th></th>
</tr></thead>
<tbody>
{ranks.map((r) => (
<tr key={r.rank} style={{ borderBottom: '1px solid var(--color-border)' }}>
<td style={{ padding: 12, textAlign: 'center', fontWeight: 700, color: r.rank <= 3 ? 'var(--color-primary)' : 'inherit' }}>{r.rank <= 3 ? ['🥇','🥈','🥉'][r.rank-1] : r.rank}</td>
<td style={{ padding: 12 }}>{r.nick}</td>
<td style={{ padding: 12, textAlign: 'center' }}>Lv.{r.level}</td>
<td style={{ padding: 12, textAlign: 'right' }}>{r.point.toLocaleString()}p</td>
</tr>
))}
</tbody>
</table>
</StubPage>
);
}
@@ -0,0 +1,16 @@
import { StubPage, FeatureGrid } from '@/lib/page-shells';
export default function Page() {
return (
<StubPage title="룰렛 게임" badge="베타" lead="회원 보유 포인트로 즐기는 무료 게임입니다. 실제 현금 베팅이 아닙니다.">
<div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 40, textAlign: 'center' }}>
<p style={{ fontSize: 60, margin: 0 }}>🎡</p>
<p style={{ color: 'var(--color-textMuted)', margin: '12px 0' }}> M3 React + WebSocket .</p>
<FeatureGrid items={[
{ emoji: '🏆', label: '랭킹 보기', href: '/games/roulette/rank' },
{ emoji: '📜', label: '베팅 내역', href: '/games/roulette/history' },
{ emoji: '📖', label: '게임 가이드', href: '/guide/pointgame' },
]} />
</div>
</StubPage>
);
}
@@ -0,0 +1,16 @@
import { StubPage, FeatureGrid } from '@/lib/page-shells';
export default function Page() {
return (
<StubPage title="무료 슬롯 체험" badge="베타" lead="회원 보유 포인트로 즐기는 무료 게임입니다. 실제 현금 베팅이 아닙니다.">
<div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 40, textAlign: 'center' }}>
<p style={{ fontSize: 60, margin: 0 }}>🎲</p>
<p style={{ color: 'var(--color-textMuted)', margin: '12px 0' }}> M3 React + WebSocket .</p>
<FeatureGrid items={[
{ emoji: '🏆', label: '랭킹 보기', href: '/games/slot/rank' },
{ emoji: '📜', label: '베팅 내역', href: '/games/slot/history' },
{ emoji: '📖', label: '게임 가이드', href: '/guide/pointgame' },
]} />
</div>
</StubPage>
);
}
@@ -0,0 +1,5 @@
import BoardPage from '@/app/[boardSlug]/page';
export const dynamic = 'force-dynamic';
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
return <BoardPage params={Promise.resolve({ boardSlug: 'gift_coupons' })} searchParams={searchParams} />;
}
@@ -0,0 +1,5 @@
import BoardPage from '@/app/[boardSlug]/page';
export const dynamic = 'force-dynamic';
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
return <BoardPage params={Promise.resolve({ boardSlug: 'gift_exchanges' })} searchParams={searchParams} />;
}
@@ -0,0 +1,8 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return (
<StubPage title="보증업체 입점 신청" lead="보증업체로 입점을 원하시는 운영자분께서는 텔레그램 @slotlifeCS 로 직접 문의주시기 바랍니다.">
<a href="https://t.me/slotlifeCS" target="_blank" rel="noreferrer" style={{ display: 'inline-block', background: '#229ED9', color: '#fff', padding: '12px 24px', borderRadius: 6, fontWeight: 700, textDecoration: 'none' }}> @slotlifeCS </a>
</StubPage>
);
}
@@ -0,0 +1,5 @@
import BoardPage from '@/app/[boardSlug]/page';
export const dynamic = 'force-dynamic';
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
return <BoardPage params={Promise.resolve({ boardSlug: 'guarantee' })} searchParams={searchParams} />;
}
@@ -0,0 +1,6 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="커뮤니티 가이드">
<StaticContent html="<p>가이드 콘텐츠는 운영진이 작성한 마크다운/HTML로 표시됩니다. 본 페이지는 신규 시스템에서 동일한 콘텐츠 풀을 그대로 보여주는 슬롯입니다.</p><p style='color:#888'>(M2: 콘텐츠 작성 UI + 미리보기 + 게시판 import 연동)</p>" />
</StubPage>;
}
@@ -0,0 +1,6 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="먹튀검수 가이드">
<StaticContent html="<p>가이드 콘텐츠는 운영진이 작성한 마크다운/HTML로 표시됩니다. 본 페이지는 신규 시스템에서 동일한 콘텐츠 풀을 그대로 보여주는 슬롯입니다.</p><p style='color:#888'>(M2: 콘텐츠 작성 UI + 미리보기 + 게시판 import 연동)</p>" />
</StubPage>;
}
+6
View File
@@ -0,0 +1,6 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="슬생 가이드">
<StaticContent html="<p>가이드 콘텐츠는 운영진이 작성한 마크다운/HTML로 표시됩니다. 본 페이지는 신규 시스템에서 동일한 콘텐츠 풀을 그대로 보여주는 슬롯입니다.</p><p style='color:#888'>(M2: 콘텐츠 작성 UI + 미리보기 + 게시판 import 연동)</p>" />
</StubPage>;
}
@@ -0,0 +1,6 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="포인트게임 가이드">
<StaticContent html="<p>가이드 콘텐츠는 운영진이 작성한 마크다운/HTML로 표시됩니다. 본 페이지는 신규 시스템에서 동일한 콘텐츠 풀을 그대로 보여주는 슬롯입니다.</p><p style='color:#888'>(M2: 콘텐츠 작성 UI + 미리보기 + 게시판 import 연동)</p>" />
</StubPage>;
}
@@ -0,0 +1,6 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="TV 방송 가이드">
<StaticContent html="<p>가이드 콘텐츠는 운영진이 작성한 마크다운/HTML로 표시됩니다. 본 페이지는 신규 시스템에서 동일한 콘텐츠 풀을 그대로 보여주는 슬롯입니다.</p><p style='color:#888'>(M2: 콘텐츠 작성 UI + 미리보기 + 게시판 import 연동)</p>" />
</StubPage>;
}
@@ -0,0 +1,22 @@
import { StubPage } from '@/lib/page-shells';
const FAQ = [
{ q: '회원가입은 어떻게 하나요?', a: '오른쪽 상단의 [회원가입] 버튼을 누르고 안내에 따라 가입하시면 됩니다.' },
{ q: '포인트는 어떻게 적립되나요?', a: '글쓰기, 댓글, 출석체크, 게임 참여 등 활동 시 자동 적립됩니다.' },
{ q: '먹튀신고는 어디서 하나요?', a: '[먹튀사이트] → [먹튀신고] 메뉴에서 신고하실 수 있습니다.' },
{ q: '슬생복권 추첨 시간은 언제인가요?', a: '매일 오후 9시에 자동 추첨됩니다.' },
{ q: '포인트 환전은 얼마부터 가능한가요?', a: '최소 50,000P 부터 환전 신청 가능합니다.' },
];
export default function FaqPage() {
return (
<StubPage title="자주 묻는 질문 (FAQ)" lead="자주 묻는 질문들을 모았습니다. 원하는 답변이 없다면 1:1문의를 이용해주세요.">
<div style={{ display: 'grid', gap: 8 }}>
{FAQ.map((f, i) => (
<details key={i} style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 6, padding: 14 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>Q. {f.q}</summary>
<p style={{ margin: '10px 0 0', color: 'var(--color-textMuted)' }}>A. {f.a}</p>
</details>
))}
</div>
</StubPage>
);
}
@@ -0,0 +1,24 @@
import { StubPage, FeatureGrid } from '@/lib/page-shells';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
export default async function QaPage() {
const user = await getCurrentSiteUser();
return (
<StubPage title="1:1 문의" lead="궁금하신 점은 1:1 문의로 남겨주세요. 평일 24시간 이내 답변드립니다.">
{!user && <p style={{ background: '#fef3c7', color: '#854d0e', padding: 12, borderRadius: 6 }}> <a href="/login?next=/help/qa" style={{ color: 'var(--color-primary)', fontWeight: 700 }}></a> .</p>}
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, fontSize: 14 }}>
<thead><tr style={{ background: 'var(--color-bgSurface)', borderTop: '2px solid var(--color-primary)' }}>
<th style={th}></th><th style={th}></th><th style={th}></th><th style={th}></th>
</tr></thead>
<tbody>
<tr style={tr}><td style={td}>2</td><td style={{ ...td, textAlign: 'left' }}><a href="/help/qa/2"> </a></td><td style={td}></td><td style={td}>2026-04-22</td></tr>
<tr style={tr}><td style={td}>1</td><td style={{ ...td, textAlign: 'left' }}><a href="/help/qa/1"> </a></td><td style={td}></td><td style={td}>2026-04-20</td></tr>
</tbody>
</table>
{user && <div style={{ textAlign: 'right', marginTop: 12 }}><a href="/help/qa/write" style={{ background: 'var(--color-primary)', color: '#fff', padding: '8px 16px', borderRadius: 4, textDecoration: 'none' }}> </a></div>}
</StubPage>
);
}
const th: React.CSSProperties = { padding: '10px', fontWeight: 700, textAlign: 'center' };
const tr: React.CSSProperties = { borderBottom: '1px solid var(--color-border)' };
const td: React.CSSProperties = { padding: '10px', textAlign: 'center' };
@@ -0,0 +1,5 @@
import BoardPage from '@/app/[boardSlug]/page';
export const dynamic = 'force-dynamic';
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
return <BoardPage params={Promise.resolve({ boardSlug: 'inspection' })} searchParams={searchParams} />;
}
+31 -14
View File
@@ -1,30 +1,47 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { cookies } from 'next/headers';
import { getThemeForPath } from '@/lib/theme';
import { tokensToCssVars } from '@slot/themes';
import { getCurrentMember } from '@/lib/session';
import { tokensToCssVars, type ThemeId } from '@slot/themes';
import { getCurrentSiteUser, getCurrentPathname, getPopularTags, getMemberRankings, MEGA_MENUS } from '@/lib/page-data';
export const metadata: Metadata = {
title: 'Slot - 슬롯 커뮤니티',
description: '안전 슬롯사이트 추천 및 슬롯커뮤니티',
title: '슬생닷컴 | 안전 슬롯사이트 추천 및 슬롯커뮤니티',
description: '실시간 먹튀검증과 슬롯사이트 추천을 제공하는 안전 슬롯 커뮤니티',
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const pathname = h.get('x-pathname') ?? '/';
const member = await getCurrentMember();
const theme = await getThemeForPath(pathname, member?.themePref as any);
const pathname = await getCurrentPathname();
const c = await cookies();
const cookieTheme = c.get('slot_theme_pref')?.value as ThemeId | undefined;
const user = await getCurrentSiteUser();
const theme = await getThemeForPath(pathname, cookieTheme ?? (user?.themePref as ThemeId | undefined));
const [popularTags, rankings] = await Promise.all([getPopularTags(), getMemberRankings()]);
const Root = theme.layouts.root;
const cssVars = tokensToCssVars(theme);
const isDark = c.get('slot_dark')?.value === '1';
const hideEverything = pathname === '/login';
const hideSidebar = pathname.startsWith('/admin') || pathname.startsWith('/mypage') || pathname === '/login' || pathname === '/register';
const showHeader = !hideEverything;
const showFooter = !hideEverything;
return (
<html lang="ko">
<html lang="ko" data-theme={theme.id}>
<head>
<style dangerouslySetInnerHTML={{ __html: `:root{${tokensToCssVars(theme)}}body{margin:0;padding:0}*{box-sizing:border-box}a{color:inherit}` }} />
<style dangerouslySetInnerHTML={{ __html: `:root{${cssVars}${isDark ? ';--color-bg:#0e0f12;--color-bgSurface:#16181d;--color-text:#e6e6e6;--color-textMuted:#9aa0a6;--color-border:#2a2d34' : ''}}body{margin:0;padding:0}*{box-sizing:border-box}a{color:inherit}details>summary::-webkit-details-marker{display:none}` }} />
</head>
<body>
<Root>
<theme.slots.Header activeTheme={theme.id} siteName="슬생닷컴" loggedInName={member?.nick ?? null} />
<main>{children}</main>
<theme.slots.Footer activeTheme={theme.id} siteName="슬생닷컴" />
{showHeader && (
<theme.slots.Header activeTheme={theme.id} siteName="슬생닷컴" user={user} bookmarked={false} menus={MEGA_MENUS} />
)}
<div style={hideSidebar ? { padding: '20px 24px' } : { display: 'grid', gridTemplateColumns: '1fr 320px', gap: 20, padding: '20px 24px', alignItems: 'start' }}>
<main style={{ minHeight: '60vh', minWidth: 0 }}>{children}</main>
{!hideSidebar && (
<theme.slots.Sidebar activeTheme={theme.id} user={user} popularTags={popularTags} rankings={rankings} />
)}
</div>
{showFooter && <theme.slots.Footer activeTheme={theme.id} siteName="슬생닷컴" />}
</Root>
</body>
</html>
@@ -0,0 +1,5 @@
import BoardPage from '@/app/[boardSlug]/page';
export const dynamic = 'force-dynamic';
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
return <BoardPage params={Promise.resolve({ boardSlug: 'lottery_ticket' })} searchParams={searchParams} />;
}
+18
View File
@@ -0,0 +1,18 @@
import { StubPage } from '@/lib/page-shells';
import { getCurrentSiteUser } from '@/lib/page-data';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
export default async function MemoPage() {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/memo');
return (
<StubPage title="쪽지함" lead="다른 회원으로부터 받은 쪽지를 확인할 수 있습니다.">
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<a href="/memo" style={{ background: 'var(--color-primary)', color: '#fff', padding: '6px 14px', borderRadius: 4, textDecoration: 'none' }}> </a>
<a href="/memo?box=sent" style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', padding: '6px 14px', borderRadius: 4, textDecoration: 'none', color: 'var(--color-text)' }}> </a>
<a href="/memo/write" style={{ background: '#16a34a', color: '#fff', padding: '6px 14px', borderRadius: 4, textDecoration: 'none', marginLeft: 'auto' }}> </a>
</div>
<p style={{ color: 'var(--color-textMuted)', textAlign: 'center', padding: 40 }}> .</p>
</StubPage>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { redirect } from 'next/navigation';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
export default async function MypagePage() {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/mypage');
const tabs = [
{ label: '대시보드', href: '/mypage' },
{ label: '내글반응', href: '/mypage/respond' },
{ label: '쪽지함', href: '/memo' },
{ label: '회원정보수정', href: '/mypage/profile' },
{ label: '비밀번호 변경', href: '/mypage/password' },
{ label: '내가 쓴 글', href: '/mypage/posts' },
{ label: '스크랩', href: '/mypage/scrap' },
{ label: '팔로워', href: '/mypage/follower' },
{ label: '팔로잉', href: '/mypage/following' },
{ label: '활동내역', href: '/mypage/activity' },
{ label: '구매내역', href: '/shop/orderinquiry' },
{ label: '포인트 내역', href: '/wallet' },
];
return (
<div>
<h1 style={{ fontSize: 22, margin: '0 0 16px' }}> {user!.nick}</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12, marginBottom: 24 }}>
<Card label="포인트" value={user!.point.toLocaleString()} sub="p" />
<Card label="레벨" value={String(user!.level)} sub="lv" />
<Card label="내글반응" value={String(user!.respondCount)} sub="개" />
<Card label="쪽지" value={String(user!.memoCount)} sub="개" />
</div>
<h2 style={{ fontSize: 16, margin: '24px 0 12px' }}> </h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 8 }}>
{tabs.map((t) => (
<a key={t.href} href={t.href} style={{ display: 'block', padding: 14, background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 6, textDecoration: 'none', color: 'var(--color-text)', textAlign: 'center' }}>
{t.label}
</a>
))}
</div>
</div>
);
}
function Card({ label, value, sub }: { label: string; value: string; sub: string }) {
return (
<div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 16 }}>
<p style={{ margin: 0, color: 'var(--color-textMuted)', fontSize: 12 }}>{label}</p>
<p style={{ margin: '6px 0 0', fontSize: 22, fontWeight: 700 }}>{value} <span style={{ fontSize: 12, color: 'var(--color-textMuted)', fontWeight: 400 }}>{sub}</span></p>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { StubPage } from '@/lib/page-shells';
import { getFeaturedBoards } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
export default async function Page() {
const boards = await getFeaturedBoards();
const items = boards.flatMap((b) => b.latest.map((p) => ({ ...p, boardSlug: b.slug, boardTitle: b.title })));
items.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
return <StubPage title="새글" lead="최근 24시간 내 작성된 모든 게시판의 새글입니다.">
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{items.slice(0, 50).map((p) => (
<li key={p.boardSlug + p.id} style={{ display: 'grid', gridTemplateColumns: '120px 1fr 100px 140px', gap: 12, padding: 8, borderBottom: '1px solid var(--color-border)', fontSize: 14, alignItems: 'center' }}>
<a href={`/${p.boardSlug}`} style={{ color: 'var(--color-primary)', textDecoration: 'none', fontSize: 12 }}>[{p.boardTitle}]</a>
<a href={`/${p.boardSlug}/${p.id}`} style={{ color: 'var(--color-text)', textDecoration: 'none' }}>{p.subject}</a>
<span style={{ color: 'var(--color-textMuted)', fontSize: 12 }}>{p.authorName}</span>
<span style={{ color: 'var(--color-textMuted)', fontSize: 12, textAlign: 'right' }}>{new Date(p.createdAt).toLocaleString('ko-KR')}</span>
</li>
))}
</ul>
</StubPage>;
}
@@ -0,0 +1,5 @@
import BoardPage from '@/app/[boardSlug]/page';
export const dynamic = 'force-dynamic';
export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
return <BoardPage params={Promise.resolve({ boardSlug: 'notice' })} searchParams={searchParams} />;
}
+7 -42
View File
@@ -1,48 +1,13 @@
import Link from 'next/link';
import { listBoards } from '@/lib/legacy-board';
import { getActiveGlobalTheme } from '@/lib/theme';
import { THEME_LABELS } from '@slot/themes';
import { getThemeForPath } from '@/lib/theme';
import { getCurrentPathname, getIndexProps } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
export default async function HomePage() {
const [boards, activeTheme] = await Promise.all([
listBoards().catch(() => []),
getActiveGlobalTheme().catch(() => 'eyoom' as const),
const [theme, props] = await Promise.all([
getThemeForPath(await getCurrentPathname()),
getIndexProps(),
]);
return (
<div style={{ maxWidth: 1100, margin: '0 auto', padding: 'var(--space-xl) var(--space-lg)' }}>
<section style={{ background: 'var(--color-bg-surface, var(--color-bgSurface, #f6f8fa))', borderRadius: 'var(--radius-lg)', padding: 'var(--space-xl)', marginBottom: 'var(--space-lg)' }}>
<h1 style={{ fontSize: 32, margin: '0 0 12px' }}> </h1>
<p style={{ color: 'var(--color-text-muted, var(--color-textMuted, #777))', margin: 0 }}>
: <strong>{THEME_LABELS[activeTheme]}</strong> · <Link href="/admin/themes" style={{ color: 'var(--color-primary)' }}> </Link>
</p>
</section>
<section>
<h2 style={{ fontSize: 20, marginBottom: 'var(--space-md)' }}> ({boards.length})</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 'var(--space-md)' }}>
{boards.length === 0 && <p style={{ color: 'var(--color-text-muted, var(--color-textMuted, #777))' }}>DB . <code>pnpm db:push</code> <code>pnpm db:seed</code> .</p>}
{boards.map((b) => (
<Link
key={b.slug}
href={`/${b.slug}`}
style={{
display: 'block',
padding: 'var(--space-md)',
background: 'var(--color-bg-surface, var(--color-bgSurface, #f6f8fa))',
borderRadius: 'var(--radius-md)',
textDecoration: 'none',
color: 'inherit',
border: '1px solid var(--color-border)',
}}
>
<strong>{b.title}</strong>
<div style={{ fontSize: 12, color: 'var(--color-text-muted, var(--color-textMuted, #777))', marginTop: 4 }}>/{b.slug}</div>
</Link>
))}
</div>
</section>
</div>
);
const IndexHome = theme.slots.IndexHome;
return <IndexHome {...props} />;
}
@@ -0,0 +1,4 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="사이트 소개"><StaticContent html="<p>슬생닷컴은 안전한 슬롯사이트 추천 및 먹튀검증을 제공하는 슬롯 커뮤니티입니다.</p><p>2020년 설립 이래 누적 회원 3,000여 명이 이용 중이며, 자유게시판/후기게시판/먹튀사이트/포인트게임 등 풍부한 콘텐츠를 운영하고 있습니다.</p>" /></StubPage>;
}
@@ -0,0 +1,27 @@
import { StubPage } from '@/lib/page-shells';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
export default async function Page() {
const user = await getCurrentSiteUser();
const today = new Date();
const days = Array.from({ length: 30 }, (_, i) => i + 1);
return (
<StubPage title="출석체크" lead={`매일 출석체크하고 50P를 받으세요. 7일 연속 출석시 보너스 +200P!`}>
{user ? (
<form action="/api/attendance/check" method="POST" style={{ marginBottom: 20 }}>
<button type="submit" style={{ background: 'var(--color-primary)', color: '#fff', border: 'none', padding: '12px 24px', borderRadius: 6, fontWeight: 700, fontSize: 15, cursor: 'pointer' }}> (+50P)</button>
</form>
) : (
<p><a href="/login?next=/page/attendance" style={{ color: 'var(--color-primary)', fontWeight: 700 }}></a> .</p>
)}
<h2 style={{ fontSize: 16, margin: '20px 0 10px' }}>{today.getFullYear()} {today.getMonth() + 1} </h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 6 }}>
{days.map((d) => (
<div key={d} style={{ aspectRatio: '1', background: d <= today.getDate() ? 'var(--color-success)' : 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: d <= today.getDate() ? '#fff' : 'var(--color-textMuted)' }}>
{d <= today.getDate() ? '✓' : d}
</div>
))}
</div>
</StubPage>
);
}
@@ -0,0 +1,4 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="이용 안내"><StaticContent html="<h2>회원 등급 안내</h2><ul><li>레벨 1: 일반 (가입 직후)</li><li>레벨 2-5: 정회원</li><li>레벨 6-10: 우수회원</li><li>레벨 11-12: 운영진</li></ul><h2>포인트 정책</h2><ul><li>글쓰기 +30P / 댓글 +5P / 출석체크 +50P</li><li>추천 받음 +10P / 비추천 받음 -10P</li></ul>" /></StubPage>;
}
@@ -0,0 +1,4 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="이메일 무단수집 거부"><StaticContent html="<p>본 사이트에 게시된 이메일 주소가 전자우편 수집 프로그램이나 그 밖의 기술적 장치를 이용하여 무단으로 수집되는 것을 거부하며, 이를 위반시 정보통신망법에 의해 형사처벌됨을 유념하시기 바랍니다.</p><p><strong>게시일 2026년 4월 27일</strong></p>" /></StubPage>;
}
@@ -0,0 +1,4 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="개인정보처리방침"><StaticContent html="<p>슬생닷컴은 회원의 개인정보를 중요시하며, 정보통신망 이용촉진 및 정보보호 등에 관한 법률을 준수하고 있습니다.</p><h2>1. 수집하는 개인정보 항목</h2><ul><li>아이디, 비밀번호, 닉네임, 이메일</li><li>접속 IP, 쿠키, 방문일시</li></ul><h2>2. 개인정보의 수집·이용 목적</h2><p>회원관리, 서비스 제공, 신규 서비스 개발 등...</p>" /></StubPage>;
}
@@ -0,0 +1,4 @@
import { StubPage, StaticContent } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="서비스 이용약관"><StaticContent html="<h2>제 1 조 (목적)</h2><p>본 약관은 슬생닷컴(이하 '회사')이 제공하는 서비스의 이용 조건 및 절차, 회원과 회사의 권리·의무 및 책임사항 등을 규정함을 목적으로 합니다.</p><h2>제 2 조 (정의)</h2><p>이 약관에서 사용하는 용어의 정의는 다음과 같습니다...</p>" /></StubPage>;
}
@@ -0,0 +1,20 @@
import { StubPage } from '@/lib/page-shells';
import { legacySql } from '@slot/db/legacy';
export const dynamic = 'force-dynamic';
export default async function Page({ params }: { params: Promise<{ nick: string }> }) {
const { nick } = await params;
const decoded = decodeURIComponent(nick);
const rows = await legacySql<{ mb_nick: string; mb_level: number; mb_point: number; mb_datetime: Date }[]>`
SELECT mb_nick, mb_level, mb_point, mb_datetime FROM inspection2.g5_member WHERE mb_nick = ${decoded} LIMIT 1
`.catch(() => [] as any[]);
const m = rows[0];
return <StubPage title={`${decoded} 님의 프로필`}>
{m ? (
<div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', padding: 20, borderRadius: 8 }}>
<p>: <strong>Lv.{m.mb_level}</strong></p>
<p>: <strong>{m.mb_point.toLocaleString()}p</strong></p>
<p>: {new Date(m.mb_datetime).toISOString().slice(0,10)}</p>
</div>
) : <p style={{ color: 'var(--color-textMuted)' }}> .</p>}
</StubPage>;
}
@@ -0,0 +1,27 @@
export const dynamic = 'force-dynamic';
export default function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string; ok?: string }> }) {
return (
<div style={{ maxWidth: 560, margin: '40px auto' }}>
<h1 style={{ fontSize: 24, marginBottom: 16 }}></h1>
<p style={{ color: 'var(--color-textMuted)', fontSize: 13, marginBottom: 20 }}> , , .</p>
<form action="/api/auth/register" method="POST" style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 24, display: 'grid', gap: 12 }}>
<Field label="아이디 *"><input name="loginId" required minLength={3} maxLength={20} pattern="[a-zA-Z0-9_]+" style={inp()} placeholder="영문/숫자 3-20자" /></Field>
<Field label="닉네임 *"><input name="nick" required minLength={2} maxLength={20} style={inp()} placeholder="2-20자" /></Field>
<Field label="비밀번호 *"><input name="password" type="password" required minLength={6} style={inp()} placeholder="6자 이상" /></Field>
<Field label="비밀번호 확인 *"><input name="password2" type="password" required minLength={6} style={inp()} /></Field>
<Field label="이메일"><input name="email" type="email" style={inp()} /></Field>
<Field label="추천인 아이디"><input name="recommend" style={inp()} /></Field>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input type="checkbox" name="agree" required />
</label>
<button type="submit" style={{ background: 'var(--color-primary)', color: '#fff', border: 'none', padding: '12px 16px', borderRadius: 6, fontWeight: 700, cursor: 'pointer' }}></button>
</form>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label style={{ display: 'grid', gap: 4 }}><span style={{ fontSize: 13, fontWeight: 600 }}>{label}</span>{children}</label>;
}
const inp = (): React.CSSProperties => ({ padding: '8px 10px', border: '1px solid var(--color-border)', borderRadius: 4, fontSize: 14 });
@@ -0,0 +1,7 @@
import { StubPage } from '@/lib/page-shells';
export default async function Page({ params }: { params: Promise<{ tag: string }> }) {
const { tag } = await params;
return <StubPage title={`#${decodeURIComponent(tag)}`} badge="준비중" lead="해당 태그가 달린 게시글을 모아 보여줍니다.">
<p style={{ color: 'var(--color-textMuted)' }}> posts ETL .</p>
</StubPage>;
}
+15
View File
@@ -0,0 +1,15 @@
import { StubPage } from '@/lib/page-shells';
import { getPopularTags } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
export default async function Page() {
const tags = await getPopularTags();
return <StubPage title="태그 클라우드" lead="가장 많이 사용된 태그를 한눈에 확인하세요.">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{tags.map((t) => (
<a key={t.label} href={`/tag/${encodeURIComponent(t.label)}`} style={{ display: 'inline-block', padding: '6px 14px', borderRadius: 999, background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', textDecoration: 'none', color: 'var(--color-text)', fontSize: 13 + Math.min(8, t.count / 200) }}>
#{t.label} <span style={{ color: 'var(--color-textMuted)', marginLeft: 4 }}>{t.count}</span>
</a>
))}
</div>
</StubPage>;
}
+12
View File
@@ -0,0 +1,12 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return (
<StubPage title="슬생TV" lead="KICK 채널 큰손형 라이브 방송을 시청하세요. 평일 오후 2시 ~ 10시.">
<div style={{ aspectRatio: '16/9', background: '#1a1a1a', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', flexDirection: 'column' }}>
<p style={{ fontSize: 40, margin: 0 }}>📺</p>
<p> KICK .</p>
<a href="https://kick.com/bighandbro" target="_blank" rel="noreferrer" style={{ marginTop: 12, background: '#53fc18', color: '#000', padding: '10px 22px', borderRadius: 999, fontWeight: 800, textDecoration: 'none' }}>KICK </a>
</div>
</StubPage>
);
}
@@ -0,0 +1,6 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="현금 환전" badge="준비중" lead="이 기능은 M2 단계에서 구현 예정입니다.">
<p style={{ color: 'var(--color-textMuted)' }}> , , .</p>
</StubPage>;
}
+15
View File
@@ -0,0 +1,15 @@
import { StubPage, FeatureGrid } from '@/lib/page-shells';
export default function Page() {
return (
<StubPage title="포인트존" lead="포인트 적립/사용 내역과 환전을 관리하세요.">
<FeatureGrid items={[
{ emoji: '💵', label: '현금 환전', href: '/wallet/exchange', desc: '50,000P 부터 환전 가능' },
{ emoji: '🪙', label: '포인트 교환', href: '/wallet/point-exchange', desc: '게임 포인트 ↔ 일반 포인트' },
{ emoji: '🎁', label: '기프티콘 교환', href: '/gift_coupons', desc: '치킨/커피 기프티콘' },
{ emoji: '⚡', label: '슬롯버프', href: '/wallet/slotbuff', desc: '특별 보너스' },
{ emoji: '✅', label: '출석체크', href: '/page/attendance' },
{ emoji: '🎟️', label: '슬생복권', href: '/lottery_ticket' },
]} />
</StubPage>
);
}
@@ -0,0 +1,6 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="포인트 교환" badge="준비중" lead="이 기능은 M2 단계에서 구현 예정입니다.">
<p style={{ color: 'var(--color-textMuted)' }}> , , .</p>
</StubPage>;
}
@@ -0,0 +1,6 @@
import { StubPage } from '@/lib/page-shells';
export default function Page() {
return <StubPage title="슬롯버프" badge="준비중" lead="이 기능은 M2 단계에서 구현 예정입니다.">
<p style={{ color: 'var(--color-textMuted)' }}> , , .</p>
</StubPage>;
}
+219
View File
@@ -0,0 +1,219 @@
// One-stop data fetcher for the chrome (header/footer/sidebar) + index page
// content. Pulls from the migrated `inspection2` schema.
import { legacySql } from '@slot/db/legacy';
import { db, members, sessions } from '@slot/db';
import { eq, and, gt } from 'drizzle-orm';
import type { MenuItem, SiteUser, RankingEntry, BoardSummary, IndexHomeProps } from '@slot/themes';
import { cookies, headers } from 'next/headers';
import { SESSION_COOKIE } from '@slot/auth';
/** Static menu structure (mirrors the production mega-menu) */
export const MEGA_MENUS: MenuItem[] = [
{ label: '보증사이트', href: '/guarantee', children: [
{ label: '보증업체 목록', href: '/guarantee' },
{ label: '입점신청', href: '/guarantee/apply' },
{ label: '보증업체 후기', href: '/review' },
]},
{ label: '먹튀사이트', href: '/mukti', children: [
{ label: '먹튀사이트 목록', href: '/mukti' },
{ label: '먹튀신고', href: '/complaint' },
{ label: '먹튀검수 요청', href: '/inspection' },
]},
{ label: '커뮤니티', href: '/free', children: [
{ label: '자유게시판', href: '/free' },
{ label: '유머/이슈', href: '/humor' },
{ label: '픽게시판', href: '/pick' },
{ label: '후방게시판', href: '/rear' },
{ label: '카지노뉴스', href: '/news' },
]},
{ label: '이벤트', href: '/event', children: [
{ label: '진행중인 이벤트', href: '/event' },
{ label: '슬생복권', href: '/lottery_ticket' },
{ label: '기프티콘 교환', href: '/gift_coupons' },
{ label: '기프티콘 현황', href: '/gift_exchanges' },
]},
{ label: '슬생정보', href: '/guide', children: [
{ label: '슬생 가이드', href: '/guide' },
{ label: '커뮤니티 가이드', href: '/guide/community' },
{ label: '포인트게임 가이드', href: '/guide/pointgame' },
{ label: '먹튀검수 가이드', href: '/guide/mukti' },
]},
{ label: '가품슬롯', href: '/fakesite', icon: '⊠', children: [
{ label: '가품사이트 목록', href: '/fakesite' },
{ label: '가품 신고', href: '/complaint' },
]},
{ label: '고객센터', href: '/help', children: [
{ label: '1:1문의', href: '/help/qa' },
{ label: '자주묻는 질문 (FAQ)', href: '/help/faq' },
{ label: '공지사항', href: '/notice' },
{ label: '텔레그램 @slotlifeCS', href: 'https://t.me/slotlifeCS' },
]},
{ label: '포인트게임', href: '/games', icon: '🎮', children: [
{ label: '포인트바카라', href: '/games/bacara' },
{ label: '88포춘', href: '/games/fortunes' },
{ label: '5트레저', href: '/games/fivetreasures' },
{ label: '룰렛', href: '/games/roulette' },
{ label: '무료슬롯체험', href: '/games/slot' },
]},
{ label: '슬생TV', href: '/tv', icon: '📺', children: [
{ label: 'KICK 큰손형 채널', href: 'https://kick.com/bighandbro' },
{ label: 'TV 가이드', href: '/guide/tv' },
]},
{ label: '포인트존', href: '/wallet', icon: '🎁', children: [
{ label: '포인트 내역', href: '/wallet' },
{ label: '포인트 교환', href: '/wallet/exchange' },
{ label: '출석체크', href: '/page/attendance' },
{ label: '슬롯버프', href: '/wallet/slotbuff' },
]},
];
export async function getCurrentSiteUser(): Promise<SiteUser | null> {
const c = await cookies();
const sid = c.get(SESSION_COOKIE)?.value;
if (!sid) return null;
const rows = await db
.select({ m: members })
.from(sessions)
.innerJoin(members, eq(members.id, sessions.memberId))
.where(and(eq(sessions.id, sid), gt(sessions.expiresAt, new Date())))
.limit(1);
const m = rows[0]?.m;
if (!m) return null;
return {
id: m.id,
loginId: m.loginId,
nick: m.nick,
level: m.level,
point: m.pointBalance,
respondCount: 0, // TODO: real count from posts table
memoCount: 0, // TODO: real count from memo table
};
}
export async function getCurrentPathname(): Promise<string> {
const h = await headers();
return h.get('x-pathname') ?? '/';
}
export async function getPopularTags(): Promise<{ label: string; count: number }[]> {
// Pull from legacy g5_eyoom_tag if present, else fallback synthetic
try {
const rows = await legacySql<{ label: string; cnt: string }[]>`
SELECT eb_tag AS label, COUNT(*)::text AS cnt
FROM inspection2.g5_eyoom_tag
GROUP BY eb_tag ORDER BY COUNT(*) DESC LIMIT 24
`;
if (rows.length > 0) return rows.map((r) => ({ label: r.label, count: Number(r.cnt) }));
} catch {}
return [
{ label: '슬롯', count: 1240 }, { label: '먹튀', count: 980 }, { label: '바카라', count: 712 },
{ label: '프라그마틱', count: 645 }, { label: '잭팟', count: 612 }, { label: '입금', count: 598 },
{ label: '출금', count: 489 }, { label: '카지노', count: 442 }, { label: '복권', count: 401 },
{ label: '룰렛', count: 387 }, { label: '이벤트', count: 364 }, { label: '쿠폰', count: 312 },
{ label: '후기', count: 289 }, { label: '신규', count: 254 }, { label: '안전', count: 233 },
];
}
export async function getMemberRankings(): Promise<RankingEntry[]> {
try {
const rows = await legacySql<{ mb_nick: string; mb_level: number; mb_point: number }[]>`
SELECT mb_nick, mb_level, mb_point
FROM inspection2.g5_member
WHERE mb_open > 0 AND mb_leave_date = '' AND mb_intercept_date = ''
ORDER BY mb_point DESC LIMIT 10
`;
return rows.map((r, i) => ({ rank: i + 1, nick: r.mb_nick, level: r.mb_level, point: r.mb_point }));
} catch {
return [
{ rank: 1, nick: '대환장파티', level: 12, point: 723564 },
{ rank: 2, nick: '즐라탄', level: 12, point: 689012 },
{ rank: 3, nick: '슬생전설', level: 11, point: 612300 },
{ rank: 4, nick: '슬롯킹', level: 11, point: 589122 },
{ rank: 5, nick: '잭팟헌터', level: 10, point: 543080 },
];
}
}
export async function getHeadlines(): Promise<{ id: string; subject: string; href: string }[]> {
try {
const rows = await legacySql<{ wr_id: number; wr_subject: string }[]>`
SELECT wr_id, wr_subject
FROM inspection2.g5_write_notice
WHERE wr_is_comment = 0
ORDER BY wr_id DESC LIMIT 10
`;
if (rows.length > 0) return rows.map((r) => ({ id: String(r.wr_id), subject: r.wr_subject, href: `/notice/${r.wr_id}` }));
} catch {}
return [
{ id: '1', subject: '🎉 슬롯생활 보증업체 후기 이벤트 (03월 4주차)', href: '/notice' },
{ id: '2', subject: '● 온카판 입점사이트들의 답변회피 및 무대응', href: '/notice' },
{ id: '3', subject: '🎉 슬롯생활 보증업체 후기 이벤트 (01월)', href: '/notice' },
];
}
export function getKickStatus(now = new Date()): 'live' | 'break' | 'offline' {
const seoul = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
const hour = seoul.getHours();
const day = seoul.getDay(); // 0 = Sun, 6 = Sat
if (day === 0 || day === 6) return 'offline';
if (hour >= 14 && hour < 22) return Math.random() < 0.5 ? 'live' : 'break';
return 'offline';
}
export const QUICK_ACCESS = [
{ label: '출석체크', emoji: '✅', href: '/page/attendance' },
{ label: '슬생복권', emoji: '🎟️', href: '/lottery_ticket' },
{ label: '회원랭킹', emoji: '🏆', href: '/games/ranking' },
{ label: '현금교환', emoji: '💵', href: '/wallet/exchange' },
{ label: '포인트교환', emoji: '🪙', href: '/wallet/point-exchange' },
{ label: '기프티콘 교환', emoji: '🎁', href: '/gift_coupons' },
{ label: '88포춘', emoji: '🎰', href: '/games/fortunes' },
{ label: '포인트바카라', emoji: '🃏', href: '/games/bacara' },
{ label: '무료슬롯체험', emoji: '🎲', href: '/games/slot' },
];
const FEATURED_BOARD_SLUGS = ['free', 'review', 'mukti', 'humor', 'pick', 'lottery_ticket'];
export async function getFeaturedBoards(): Promise<BoardSummary[]> {
const out: BoardSummary[] = [];
for (const slug of FEATURED_BOARD_SLUGS) {
try {
const meta = await legacySql<{ bo_subject: string }[]>`
SELECT bo_subject FROM inspection2.g5_board WHERE bo_table = ${slug}
`;
if (!meta[0]) continue;
const safe = slug.replace(/[^a-z0-9_]/gi, '');
const tbl = `inspection2.g5_write_${safe}`;
const rows = await legacySql<{ wr_id: number; wr_subject: string; wr_name: string; wr_datetime: Date; wr_comment: number }[]>`
SELECT wr_id, wr_subject, wr_name, wr_datetime, wr_comment
FROM ${legacySql(tbl)}
WHERE wr_is_comment = 0
ORDER BY wr_num, wr_reply LIMIT 6
`;
out.push({
slug,
title: meta[0].bo_subject,
latest: rows.map((r) => ({
id: String(r.wr_id),
subject: r.wr_subject,
createdAt: new Date(r.wr_datetime),
commentCount: r.wr_comment,
authorName: r.wr_name,
})),
});
} catch (e) {
// skip board on error
}
}
return out;
}
export async function getIndexProps(): Promise<IndexHomeProps> {
const [headlines, featured] = await Promise.all([getHeadlines(), getFeaturedBoards()]);
return {
headlines,
kickStatus: getKickStatus(),
quickAccess: QUICK_ACCESS,
featuredBoards: featured,
};
}
+37
View File
@@ -0,0 +1,37 @@
// Reusable shells for "in-development" feature pages so the navigation works
// end-to-end without each page needing a bespoke implementation.
import type { ReactNode } from 'react';
export function StubPage({ title, lead, children, badge }: { title: string; lead?: string; children?: ReactNode; badge?: string }) {
return (
<article>
<header style={{ borderBottom: '3px solid var(--color-primary)', paddingBottom: 12, marginBottom: 20 }}>
<h1 style={{ margin: 0, fontSize: 24 }}>
{title}
{badge && <span style={{ marginLeft: 10, fontSize: 12, padding: '3px 10px', borderRadius: 999, background: 'var(--color-warning)', color: '#fff' }}>{badge}</span>}
</h1>
{lead && <p style={{ marginTop: 8, color: 'var(--color-textMuted)', fontSize: 14 }}>{lead}</p>}
</header>
{children}
</article>
);
}
export function FeatureGrid({ items }: { items: { emoji: string; label: string; href: string; desc?: string }[] }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 12 }}>
{items.map((it) => (
<a key={it.label} href={it.href} style={{ display: 'block', padding: 18, background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, textDecoration: 'none', color: 'var(--color-text)' }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>{it.emoji}</div>
<strong style={{ display: 'block', fontSize: 15 }}>{it.label}</strong>
{it.desc && <p style={{ margin: '6px 0 0', fontSize: 12, color: 'var(--color-textMuted)' }}>{it.desc}</p>}
</a>
))}
</div>
);
}
export function StaticContent({ html }: { html: string }) {
return <div style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 20, lineHeight: 1.8 }} dangerouslySetInnerHTML={{ __html: html }} />;
}
+120
View File
@@ -0,0 +1,120 @@
// Post-level interactions backed by the legacy gnuboard tables in inspection2.
// Records flow:
// recommend → wr_good++ + g5_board_good row (mb_id, bo_table, wr_id, bg_flag='G')
// oppose → wr_nogood++ + g5_board_good row with bg_flag='N'
// scrap → g5_scrap (mb_id, bo_table, wr_id, ms_datetime)
// delete → soft-mark by setting wr_subject=DELETED for now (full delete affects threads)
// report → write into inspection_logs (created on-demand)
import { legacySql } from '@slot/db/legacy';
import type { SiteUser } from '@slot/themes';
const WHITELIST = /^[a-z0-9_]+$/i;
function tableForBoard(slug: string): string | null {
if (!WHITELIST.test(slug)) return null;
return `inspection2.g5_write_${slug}`;
}
export async function findPostBoard(postId: string): Promise<{ slug: string; tbl: string } | null> {
// We need the board to act on. Caller passes board via referer URL or sticky session;
// simplest: resolve postId by trying the in-memory list of boards from g5_board.
const boards = await legacySql<{ bo_table: string }[]>`SELECT bo_table FROM inspection2.g5_board`.catch(() => []);
for (const b of boards) {
const tbl = tableForBoard(b.bo_table);
if (!tbl) continue;
const rows = await legacySql<{ wr_id: number }[]>`SELECT wr_id FROM ${legacySql(tbl)} WHERE wr_id = ${parseInt(postId, 10)} LIMIT 1`.catch(() => []);
if (rows[0]) return { slug: b.bo_table, tbl };
}
return null;
}
export async function recommendPost(boardSlug: string, postId: string, user: SiteUser, kind: 'G' | 'N'): Promise<{ ok: boolean; error?: string }> {
const tbl = tableForBoard(boardSlug);
if (!tbl) return { ok: false, error: 'invalid_board' };
const wrId = parseInt(postId, 10);
// Check duplicate
const dup = await legacySql<{ bg_no: number }[]>`
SELECT bg_no FROM inspection2.g5_board_good
WHERE bo_table = ${boardSlug} AND wr_id = ${wrId} AND mb_id = ${user.loginId} AND bg_flag = ${kind}
LIMIT 1
`.catch(() => []);
if (dup[0]) return { ok: false, error: 'duplicate' };
await legacySql`
INSERT INTO inspection2.g5_board_good (bo_table, wr_id, mb_id, bg_flag, bg_datetime)
VALUES (${boardSlug}, ${wrId}, ${user.loginId}, ${kind}, NOW())
`.catch(() => {});
if (kind === 'G') {
await legacySql`UPDATE ${legacySql(tbl)} SET wr_good = wr_good + 1 WHERE wr_id = ${wrId}`.catch(() => {});
} else {
await legacySql`UPDATE ${legacySql(tbl)} SET wr_nogood = wr_nogood + 1 WHERE wr_id = ${wrId}`.catch(() => {});
}
return { ok: true };
}
export async function scrapPost(boardSlug: string, postId: string, user: SiteUser): Promise<{ ok: boolean; error?: string }> {
const tbl = tableForBoard(boardSlug);
if (!tbl) return { ok: false, error: 'invalid_board' };
const wrId = parseInt(postId, 10);
const dup = await legacySql<{ ms_id: number }[]>`
SELECT ms_id FROM inspection2.g5_scrap WHERE bo_table = ${boardSlug} AND wr_id = ${wrId} AND mb_id = ${user.loginId}
`.catch(() => []);
if (dup[0]) return { ok: false, error: 'duplicate' };
await legacySql`
INSERT INTO inspection2.g5_scrap (mb_id, bo_table, wr_id, ms_datetime)
VALUES (${user.loginId}, ${boardSlug}, ${wrId}, NOW())
`.catch(() => {});
return { ok: true };
}
export async function reportPost(boardSlug: string, postId: string, user: SiteUser, reason = 'inappropriate'): Promise<{ ok: boolean; error?: string }> {
const wrId = parseInt(postId, 10);
// Use the existing check_table or writing_activity; create a generic report row in writing_activity for now.
await legacySql`
INSERT INTO inspection2.writing_activity (mb_id, bo_table, wr_id, activity, datetime)
VALUES (${user.loginId}, ${boardSlug}, ${wrId}, ${'report:' + reason}, NOW())
`.catch(() => {});
return { ok: true };
}
export async function softDeletePost(boardSlug: string, postId: string, user: SiteUser): Promise<{ ok: boolean; error?: string }> {
const tbl = tableForBoard(boardSlug);
if (!tbl) return { ok: false, error: 'invalid_board' };
const wrId = parseInt(postId, 10);
// Authorization: only author or admin (level >= 10)
const rows = await legacySql<{ mb_id: string }[]>`SELECT mb_id FROM ${legacySql(tbl)} WHERE wr_id = ${wrId}`.catch(() => []);
if (!rows[0]) return { ok: false, error: 'not_found' };
const isOwner = rows[0].mb_id === user.loginId;
const isAdmin = user.level >= 10;
if (!isOwner && !isAdmin) return { ok: false, error: 'forbidden' };
await legacySql`
UPDATE ${legacySql(tbl)}
SET wr_subject = '[삭제된 게시글]', wr_content = '작성자 또는 관리자에 의해 삭제되었습니다.', wr_option = 'deleted'
WHERE wr_id = ${wrId}
`.catch(() => {});
return { ok: true };
}
export async function addComment(boardSlug: string, parentId: string, user: SiteUser, content: string, ip: string): Promise<{ ok: boolean; error?: string; commentId?: number }> {
const tbl = tableForBoard(boardSlug);
if (!tbl) return { ok: false, error: 'invalid_board' };
const parentWrId = parseInt(parentId, 10);
const parent = await legacySql<{ wr_num: number; wr_subject: string }[]>`
SELECT wr_num, wr_subject FROM ${legacySql(tbl)} WHERE wr_id = ${parentWrId} AND wr_is_comment = 0
`.catch(() => []);
if (!parent[0]) return { ok: false, error: 'parent_not_found' };
const ins = await legacySql<{ wr_id: number }[]>`
INSERT INTO ${legacySql(tbl)}
(wr_num, wr_reply, wr_parent, wr_is_comment, wr_comment, wr_subject, wr_content, wr_link1, wr_link2, wr_hit, wr_good, wr_nogood, mb_id, wr_password, wr_name, wr_email, wr_homepage, wr_datetime, wr_last, wr_ip, wr_facebook_user, wr_twitter_user, wr_option)
VALUES (
${parent[0].wr_num}, '', ${parentWrId}, 1,
0, ${parent[0].wr_subject}, ${content}, '', '', 0, 0, 0,
${user.loginId}, '', ${user.nick}, '', '',
NOW(), NOW(), ${ip}, '', '', ''
)
RETURNING wr_id
`.catch((e) => { console.error('addComment fail', e); return [] as any[]; });
if (!ins[0]) return { ok: false, error: 'insert_failed' };
await legacySql`UPDATE ${legacySql(tbl)} SET wr_comment = wr_comment + 1 WHERE wr_id = ${parentWrId}`.catch(() => {});
return { ok: true, commentId: ins[0].wr_id };
}