Customizable home layout + 11 more admin pages (45 admin total now)

Home layout (gnuboard-style widget configurator):
- /admin/eyoom/main-layout: toggle each section, set order, edit board slugs
- public.app_settings.home_layout = { sections: [{ id, enabled, order, boards }] }
- app/page.tsx reads layout, renders sections in admin-defined order
- getFeaturedBoards now accepts override slug list (admin-controlled featured boards)
- 7 toggleable widgets: hero, statStrip, topWinners, hotBoards, quickAccess, boardSlots, liveActivity

Shop admin (영카트):
- /admin/shop/brands: brand-grouped item counts
- /admin/shop/couponzone: coupon-zone create/delete
- /admin/shop/buylist: per-member purchase totals (mb_id, count, sum)

Members admin:
- /admin/members/visits: 60-day bar chart (g5_visit_sum)
- /admin/members/poll: g5_poll create / toggle use

Boards admin:
- /admin/boards/popular: 30-day search-keyword heatmap (g5_popular)

Eyoom admin:
- /admin/eyoom/tags: g5_eyoom_tag menu-display toggle / delete
- /admin/eyoom/attendance: top 100 attendance + 30 latest

SMS admin:
- /admin/sms/history: paginated 50/page sms5_history

SEO:
- /admin/seo: ask_seo_url meta CRUD

verify-cross: now exercises 38 admin URLs as admin (was 27)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-29 12:16:34 +09:00
parent a096903dea
commit ce98dcaf27
15 changed files with 779 additions and 25 deletions
@@ -0,0 +1,50 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
}
async function clearPopular() {
'use server';
await requireAdmin();
await legacySql`DELETE FROM inspection2.g5_popular WHERE pp_date < CURRENT_DATE - INTERVAL '30 days'`.catch(() => {});
revalidatePath('/admin/boards/popular');
}
export default async function PopularAdmin() {
await requireAdmin();
const rows = await legacySql<{ pp_word: string; cnt: string }[]>`
SELECT pp_word, COUNT(*)::text AS cnt FROM inspection2.g5_popular
WHERE pp_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY pp_word ORDER BY cnt DESC LIMIT 100
`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> ( 30)</h1>
</header>
<form action={clearPopular} className="mb-4 text-right">
<button type="submit" className="rounded-lg bg-rose-50 px-3 py-1.5 text-[11px] font-bold text-rose-600">30 </button>
</form>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{rows.map((r, i) => (
<div key={r.pp_word} className="flex items-center justify-between rounded-xl bg-white p-3 ring-1 ring-neutral-100">
<span className="flex items-center gap-2 text-[12.5px]">
<span className="grid h-5 w-5 place-items-center rounded-full bg-brand-50 text-[10px] font-bold text-brand-700">{i + 1}</span>
{r.pp_word}
</span>
<span className="text-[10.5px] font-bold text-neutral-text-soft">{Number(r.cnt).toLocaleString()}</span>
</div>
))}
{rows.length === 0 && <p className="col-span-full rounded-xl border border-dashed border-neutral-200 bg-white py-8 text-center text-[12px] text-neutral-text-soft"> </p>}
</div>
</article>
);
}
@@ -0,0 +1,51 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
}
export default async function AttendanceAdmin() {
await requireAdmin();
const top = await legacySql<{ mb_id: string; cnt: string }[]>`
SELECT mb_id, COUNT(*)::text AS cnt FROM inspection2.g5_eyoom_attendance
GROUP BY mb_id ORDER BY COUNT(*) DESC LIMIT 100
`.catch(() => []);
const recent = await legacySql<{ mb_id: string; at_date: string; at_point: number }[]>`
SELECT mb_id, at_date, at_point FROM inspection2.g5_eyoom_attendance ORDER BY id DESC LIMIT 30
`.catch(() => []);
return (
<article className="grid gap-5 lg:grid-cols-2">
<section>
<header className="mb-3 border-b border-neutral-100 pb-2">
<h2 className="m-0 text-[16px] font-bold"> TOP 100</h2>
</header>
<ul className="m-0 grid gap-1 p-0 list-none">
{top.map((r, i) => (
<li key={r.mb_id} className="flex items-center justify-between rounded-lg bg-white px-3 py-1.5 text-[12.5px] ring-1 ring-neutral-100">
<span><span className="mr-2 font-bold text-brand-700">#{i + 1}</span>{r.mb_id}</span>
<strong className="tabular">{Number(r.cnt).toLocaleString()}</strong>
</li>
))}
</ul>
</section>
<section>
<header className="mb-3 border-b border-neutral-100 pb-2">
<h2 className="m-0 text-[16px] font-bold"> 30 </h2>
</header>
<ul className="m-0 grid gap-1 p-0 list-none">
{recent.map((r, i) => (
<li key={i} className="flex items-center justify-between rounded-lg bg-white px-3 py-1.5 text-[12.5px] ring-1 ring-neutral-100">
<span>{r.mb_id} · <span className="text-[10.5px] text-neutral-text-soft">{r.at_date}</span></span>
<strong className="text-emerald-700 tabular">+{Number(r.at_point ?? 0).toLocaleString()}p</strong>
</li>
))}
</ul>
</section>
</article>
);
}
@@ -0,0 +1,116 @@
import { redirect } from 'next/navigation';
import { getCurrentSiteUser } from '@/lib/page-data';
import { getHomeLayout, saveHomeLayout, DEFAULT_LAYOUT, type SectionConfig, type SectionId } from '@/lib/home-layout';
import { legacySql } from '@slot/db/legacy';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
const LABELS: Record<SectionId, string> = {
hero: '🎬 히어로 (그라데이션 배너 + 헤드라인 마퀴)',
statStrip: '📊 사이트 통계 8지표',
topWinners: '👑 포인트 랭커 TOP 5',
hotBoards: '🔥 HOT 보드 가로 캐러셀',
quickAccess: '⚡ 빠른 액세스 9 타일',
boardSlots: '📋 게시판 슬롯 (좌측)',
liveActivity: '✨ 실시간 활동 (우측 사이드)',
};
const HAS_BOARDS: SectionId[] = ['hotBoards', 'boardSlots'];
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function saveLayout(formData: FormData) {
'use server';
await requireAdmin();
const sections: SectionConfig[] = DEFAULT_LAYOUT.map((d) => {
const enabled = formData.get(`enabled_${d.id}`) === 'on';
const order = Number(formData.get(`order_${d.id}`) ?? d.order) || d.order;
const boardsRaw = String(formData.get(`boards_${d.id}`) ?? '').trim();
const boards = HAS_BOARDS.includes(d.id) && boardsRaw
? boardsRaw.split(',').map((s) => s.trim()).filter(Boolean)
: d.boards;
return { id: d.id, enabled, order, ...(boards ? { boards } : {}) };
});
await saveHomeLayout(sections);
revalidatePath('/');
revalidatePath('/admin/eyoom/main-layout');
}
async function reset() {
'use server';
await requireAdmin();
await saveHomeLayout(DEFAULT_LAYOUT);
revalidatePath('/');
revalidatePath('/admin/eyoom/main-layout');
}
export default async function MainLayoutAdmin() {
await requireAdmin();
const layout = await getHomeLayout();
const allBoards = await legacySql<{ bo_table: string; bo_subject: string }[]>`
SELECT bo_table, bo_subject FROM inspection2.g5_board WHERE bo_use_search > 0 OR bo_count_write > 100
ORDER BY bo_count_write DESC NULLS LAST LIMIT 30
`.catch(() => []);
return (
<article className="flex flex-col gap-5">
<header className="border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft"> . .</p>
</header>
<form action={saveLayout} className="grid gap-3 rounded-2xl bg-white p-5 ring-1 ring-neutral-100">
{layout.map((s) => (
<div key={s.id} className="grid items-center gap-2 border-b border-neutral-100 pb-3 last:border-b-0 last:pb-0 sm:grid-cols-[2fr_80px_1fr_70px]">
<label className="flex items-center gap-2 text-[13px] font-medium">
<input type="checkbox" name={`enabled_${s.id}`} defaultChecked={s.enabled} />
<span>{LABELS[s.id]}</span>
</label>
<div>
<label className="text-[10.5px] text-neutral-text-soft"></label>
<input type="number" name={`order_${s.id}`} defaultValue={s.order} min={1} max={20} className="w-full rounded border border-neutral-200 px-2 py-1 text-center text-[12px]" />
</div>
{HAS_BOARDS.includes(s.id) ? (
<div>
<label className="text-[10.5px] text-neutral-text-soft"> ( )</label>
<input
name={`boards_${s.id}`}
defaultValue={(s.boards ?? []).join(',')}
placeholder="free,review,mukti..."
className="w-full rounded border border-neutral-200 px-2 py-1 font-mono text-[11px]"
/>
</div>
) : (
<span className="text-[10.5px] text-neutral-300"></span>
)}
{HAS_BOARDS.includes(s.id) && (
<span className="text-right text-[10px] text-neutral-text-soft"> {(s.boards ?? []).length}</span>
)}
</div>
))}
<div className="flex justify-end gap-2">
<button type="submit" className="rounded-full bg-brand-600 px-6 py-2 text-[13px] font-bold text-white hover:bg-brand-700"></button>
</div>
</form>
<form action={reset} className="text-right">
<button type="submit" className="rounded-lg bg-rose-50 px-3 py-1.5 text-[11px] font-bold text-rose-600 hover:bg-rose-100"> </button>
</form>
<div className="rounded-xl border border-neutral-100 bg-white p-4">
<h2 className="m-0 text-[14px] font-bold text-neutral-700"> ( 100+ )</h2>
<ul className="m-0 mt-2 grid grid-cols-2 gap-1 p-0 text-[11.5px] sm:grid-cols-3 lg:grid-cols-4 list-none">
{allBoards.map((b) => (
<li key={b.bo_table}><code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[10.5px]">{b.bo_table}</code> {b.bo_subject}</li>
))}
</ul>
</div>
</article>
);
}
@@ -0,0 +1,65 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
interface Row { tg_id: number; tg_word: string; tg_regcnt: number; tg_dpmenu: string }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
}
async function toggleMenu(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('tg_id') ?? 0);
await legacySql`UPDATE inspection2.g5_eyoom_tag SET tg_dpmenu = CASE WHEN tg_dpmenu = 'y' THEN 'n' ELSE 'y' END WHERE tg_id = ${id}`.catch(() => {});
revalidatePath('/admin/eyoom/tags');
}
async function deleteTag(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('tg_id') ?? 0);
await legacySql`DELETE FROM inspection2.g5_eyoom_tag WHERE tg_id = ${id}`.catch(() => {});
revalidatePath('/admin/eyoom/tags');
}
export default async function TagsAdmin() {
await requireAdmin();
const rows = await legacySql<Row[]>`SELECT tg_id, tg_word, tg_regcnt, tg_dpmenu FROM inspection2.g5_eyoom_tag ORDER BY tg_regcnt DESC LIMIT 200`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> ({rows.length})</h1>
<p className="mt-1 text-[13px] text-neutral-text-soft"> / . .</p>
</header>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left">ID</th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-right"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-center"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.tg_id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.tg_id}</td>
<td className="px-3 py-2">#{r.tg_word}</td>
<td className="px-3 py-2 text-right tabular">{Number(r.tg_regcnt ?? 0).toLocaleString()}</td>
<td className="px-3 py-2 text-center">
<form action={toggleMenu} className="inline"><input type="hidden" name="tg_id" value={r.tg_id} /><button type="submit" className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${r.tg_dpmenu === 'y' ? 'bg-emerald-50 text-emerald-700' : 'bg-neutral-100 text-neutral-500'}`}>{r.tg_dpmenu === 'y' ? '표시' : '숨김'}</button></form>
</td>
<td className="px-3 py-2 text-center">
<form action={deleteTag} className="inline"><input type="hidden" name="tg_id" value={r.tg_id} /><button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600"></button></form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
);
}
@@ -0,0 +1,78 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
interface Row { po_id: number; po_subject: string; po_use: number; po_date: Date; po_cnt: number }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
}
async function createPoll(formData: FormData) {
'use server';
await requireAdmin();
const subject = String(formData.get('subject') ?? '').slice(0, 200);
if (!subject) return;
const today = new Date().toISOString().slice(0, 10) + ' 00:00:00';
await legacySql`
INSERT INTO inspection2.g5_poll (po_subject, po_use, po_date, po_cnt, po_etc, po_level, po_point,
po_item1, po_item2, po_item3, po_item4, po_item5, po_item6, po_item7, po_item8, po_item9,
po_cnt1, po_cnt2, po_cnt3, po_cnt4, po_cnt5, po_cnt6, po_cnt7, po_cnt8, po_cnt9, po_ips)
VALUES (DEFAULT, ${subject}, 1, ${today}, 0, '', 1, 0,
'','','','','','','','','',0,0,0,0,0,0,0,0,0,'')
`.catch(() => {});
revalidatePath('/admin/members/poll');
}
async function togglePoll(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('po_id') ?? 0);
await legacySql`UPDATE inspection2.g5_poll SET po_use = CASE WHEN po_use > 0 THEN 0 ELSE 1 END WHERE po_id = ${id}`.catch(() => {});
revalidatePath('/admin/members/poll');
}
export default async function PollAdmin() {
await requireAdmin();
const rows = await legacySql<Row[]>`SELECT po_id, po_subject, po_use, po_date, po_cnt FROM inspection2.g5_poll ORDER BY po_id DESC LIMIT 100`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
</header>
<form action={createPoll} className="mb-4 flex gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100">
<input name="subject" required placeholder="투표 주제" className="flex-1 rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white">+ </button>
</form>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left">ID</th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-right"></th><th className="px-3 py-2 text-left"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.po_id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.po_id}</td>
<td className="px-3 py-2">{r.po_subject}</td>
<td className="px-3 py-2 text-center">
<form action={togglePoll} className="inline">
<input type="hidden" name="po_id" value={r.po_id} />
<button type="submit" className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${r.po_use > 0 ? 'bg-emerald-50 text-emerald-700' : 'bg-neutral-100 text-neutral-500'}`}>{r.po_use > 0 ? '사용중' : '중지'}</button>
</form>
</td>
<td className="px-3 py-2 text-right tabular">{Number(r.po_cnt ?? 0).toLocaleString()}</td>
<td className="px-3 py-2 text-[11px]">{r.po_date && new Date(r.po_date).toISOString().slice(0,10)}</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={5} className="py-6 text-center text-[12px] text-neutral-text-soft"> </td></tr>}
</tbody>
</table>
</div>
</article>
);
}
@@ -0,0 +1,44 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
}
export default async function VisitsAdmin() {
await requireAdmin();
const rows = await legacySql<{ vs_date: Date; vs_count: number }[]>`
SELECT vs_date, vs_count FROM inspection2.g5_visit_sum ORDER BY vs_date DESC LIMIT 60
`.catch(() => []);
const total = rows.reduce((s, r) => s + Number(r.vs_count || 0), 0);
const max = rows.reduce((m, r) => Math.max(m, Number(r.vs_count || 0)), 0);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> ( 60)</h1>
<p className="mt-1 text-[13px] text-neutral-text-soft"> {total.toLocaleString()} · {max.toLocaleString()}</p>
</header>
<div className="grid gap-1 rounded-xl border border-neutral-100 bg-white p-4">
{rows.map((r) => {
const pct = max > 0 ? Math.round((Number(r.vs_count) / max) * 100) : 0;
return (
<div key={r.vs_date as any} className="flex items-center gap-2 text-[12px]">
<span className="w-24 font-mono text-[10.5px] text-neutral-600">{new Date(r.vs_date).toISOString().slice(0,10)}</span>
<div className="relative h-4 flex-1 overflow-hidden rounded bg-neutral-100">
<div className="h-full bg-gradient-to-r from-brand-500 to-fuchsia-600" style={{ width: pct + '%' }} />
</div>
<span className="w-20 text-right tabular font-bold">{Number(r.vs_count).toLocaleString()}</span>
</div>
);
})}
{rows.length === 0 && <p className="py-6 text-center text-[12px] text-neutral-text-soft"> </p>}
</div>
</article>
);
}
@@ -0,0 +1,81 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
interface SeoRow { id: number; url: string; title: string; description: string }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
}
async function saveSeo(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('id') ?? 0);
const url = String(formData.get('url') ?? '').slice(0, 250);
const title = String(formData.get('title') ?? '').slice(0, 250);
const desc = String(formData.get('description') ?? '').slice(0, 500);
if (id) {
await legacySql`UPDATE inspection2.ask_seo_url SET title = ${title}, description = ${desc} WHERE id = ${id}`.catch(() => {});
} else if (url) {
await legacySql`INSERT INTO inspection2.ask_seo_url (url, title, description) VALUES (${url}, ${title}, ${desc})`.catch(() => {});
}
revalidatePath('/admin/seo');
}
async function deleteSeo(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('id') ?? 0);
if (!id) return;
await legacySql`DELETE FROM inspection2.ask_seo_url WHERE id = ${id}`.catch(() => {});
revalidatePath('/admin/seo');
}
export default async function SeoAdmin() {
await requireAdmin();
const rows = await legacySql<SeoRow[]>`SELECT id, url, title, description FROM inspection2.ask_seo_url ORDER BY id DESC LIMIT 100`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">SEO</div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1 text-[13px] text-neutral-text-soft">URL별 title/description (ask_seo_url).</p>
</header>
<form action={saveSeo} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[1fr_1fr_2fr_auto]">
<input name="url" placeholder="/page-url" className="rounded border border-neutral-200 px-3 py-2 text-[12.5px]" />
<input name="title" placeholder="title" className="rounded border border-neutral-200 px-3 py-2 text-[12.5px]" />
<input name="description" placeholder="description" className="rounded border border-neutral-200 px-3 py-2 text-[12.5px]" />
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white">+ </button>
</form>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12px]">
<thead className="bg-neutral-50 text-[10.5px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-2 py-2 text-left">URL</th><th className="px-2 py-2 text-left">Title ()</th><th className="px-2 py-2 text-left">Description ()</th><th className="px-2 py-2 text-center"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t border-neutral-100">
<td colSpan={4} className="px-2 py-1.5">
<form action={saveSeo} className="grid items-center gap-1 sm:grid-cols-[1.5fr_1.5fr_2fr_60px]">
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="url" value={r.url} />
<code className="font-mono text-[10.5px] text-neutral-500">{r.url}</code>
<input name="title" defaultValue={r.title} className="rounded border border-neutral-200 px-2 py-1 text-[11.5px]" />
<input name="description" defaultValue={r.description} className="rounded border border-neutral-200 px-2 py-1 text-[11.5px]" />
<button type="submit" className="rounded bg-brand-600 px-2 py-1 text-[10px] font-bold text-white"></button>
</form>
</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={4} className="py-6 text-center text-[12px] text-neutral-text-soft"> </td></tr>}
</tbody>
</table>
</div>
</article>
);
}
@@ -0,0 +1,37 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
export default async function BrandsAdmin() {
await requireAdmin();
const rows = await legacySql<{ it_brand: string; cnt: string }[]>`
SELECT it_brand, COUNT(*)::text AS cnt FROM inspection2.g5_shop_item
WHERE it_brand <> '' GROUP BY it_brand ORDER BY cnt DESC LIMIT 100
`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft"> (g5_shop_item.it_brand).</p>
</header>
<ul className="m-0 grid gap-2 p-0 sm:grid-cols-2 lg:grid-cols-3 list-none">
{rows.map((r) => (
<li key={r.it_brand} className="flex items-center justify-between rounded-xl bg-white px-3 py-2 ring-1 ring-neutral-100">
<strong className="text-[13px] text-neutral-800">{r.it_brand}</strong>
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold text-brand-700">{Number(r.cnt).toLocaleString()}</span>
</li>
))}
{rows.length === 0 && <li className="rounded-xl border border-dashed border-neutral-200 bg-white py-8 text-center text-[12px] text-neutral-text-soft sm:col-span-2 lg:col-span-3"> </li>}
</ul>
</article>
);
}
@@ -0,0 +1,45 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
interface Row { mb_id: string; cnt: string; sum: string }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
}
export default async function ShopBuylistAdmin() {
await requireAdmin();
const rows = await legacySql<Row[]>`
SELECT mb_id, COUNT(*)::text AS cnt, SUM(od_cart_price)::text AS sum
FROM inspection2.g5_shop_order WHERE mb_id <> '' GROUP BY mb_id ORDER BY SUM(od_cart_price) DESC LIMIT 200
`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
</header>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-right"></th><th className="px-3 py-2 text-right">(p)</th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.mb_id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.mb_id}</td>
<td className="px-3 py-2 text-right tabular">{Number(r.cnt).toLocaleString()}</td>
<td className="px-3 py-2 text-right tabular font-bold">{Number(r.sum).toLocaleString()}p</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={3} className="py-6 text-center text-[12px] text-neutral-text-soft"> </td></tr>}
</tbody>
</table>
</div>
</article>
);
}
@@ -0,0 +1,66 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
interface Row { cz_id: number; cz_subject: string; cz_start: Date | null; cz_end: Date | null }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
}
async function createZone(formData: FormData) {
'use server';
await requireAdmin();
const subject = String(formData.get('subject') ?? '').slice(0, 200);
if (!subject) return;
const start = new Date().toISOString().slice(0, 19).replace('T', ' ');
const end = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 19).replace('T', ' ');
await legacySql`
INSERT INTO inspection2.g5_shop_coupon_zone (cz_id, cz_subject, cz_image, cz_view_check, cz_start, cz_end, cz_target, cz_count, cz_open)
VALUES (DEFAULT, ${subject}, '', 0, ${start}, ${end}, '', 0, 1)
`.catch(() => {});
revalidatePath('/admin/shop/couponzone');
}
async function deleteZone(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('cz_id') ?? 0);
if (!id) return;
await legacySql`DELETE FROM inspection2.g5_shop_coupon_zone WHERE cz_id = ${id}`.catch(() => {});
revalidatePath('/admin/shop/couponzone');
}
export default async function CouponzoneAdmin() {
await requireAdmin();
const rows = await legacySql<Row[]>`SELECT cz_id, cz_subject, cz_start, cz_end FROM inspection2.g5_shop_coupon_zone ORDER BY cz_id DESC LIMIT 100`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
</header>
<form action={createZone} className="mb-4 flex gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100">
<input name="subject" required placeholder="쿠폰존 제목" className="flex-1 rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white">+ </button>
</form>
<ul className="m-0 grid gap-2 p-0 list-none">
{rows.map((r) => (
<li key={r.cz_id} className="flex items-center justify-between rounded-xl bg-white p-3 ring-1 ring-neutral-100">
<div>
<span className="font-mono text-[10.5px] text-neutral-500">#{r.cz_id}</span>
<strong className="ml-2 text-[13px] text-neutral-800">{r.cz_subject}</strong>
<div className="text-[11px] text-neutral-text-soft">{r.cz_start && new Date(r.cz_start).toISOString().slice(0,10)} ~ {r.cz_end && new Date(r.cz_end).toISOString().slice(0,10)}</div>
</div>
<form action={deleteZone}><input type="hidden" name="cz_id" value={r.cz_id} /><button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600"></button></form>
</li>
))}
{rows.length === 0 && <li className="rounded-xl border border-dashed border-neutral-200 bg-white py-8 text-center text-[12px] text-neutral-text-soft"> </li>}
</ul>
</article>
);
}
@@ -0,0 +1,52 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
}
export default async function SmsHistoryAdmin({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
await requireAdmin();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? 1) | 0);
const offset = (page - 1) * 50;
const rows = await legacySql<{ id: number; send_hp: string; send_msg: string; send_state: string | null; send_date: Date }[]>`
SELECT id, send_hp, send_msg, send_state, send_date FROM inspection2.sms5_history ORDER BY id DESC LIMIT 50 OFFSET ${offset}
`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">SMS </div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> ( {page})</h1>
</header>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left">ID</th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-left"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.id}</td>
<td className="px-3 py-2 font-mono">{r.send_hp}</td>
<td className="px-3 py-2">{r.send_msg}</td>
<td className="px-3 py-2 text-center"><span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-bold text-emerald-700">{r.send_state ?? 'queued'}</span></td>
<td className="px-3 py-2 text-[11px]">{r.send_date && new Date(r.send_date).toISOString().slice(0,16).replace('T',' ')}</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={5} className="py-6 text-center text-[12px] text-neutral-text-soft"> </td></tr>}
</tbody>
</table>
</div>
<nav className="mt-4 flex justify-center gap-1.5">
{page > 1 && <a href={`?page=${page - 1}`} className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-[12px]"> </a>}
<span className="rounded-md bg-brand-600 px-3 py-1.5 text-[12px] font-bold text-white"> {page}</span>
<a href={`?page=${page + 1}`} className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-[12px]"> </a>
</nav>
</article>
);
}
+41 -22
View File
@@ -1,4 +1,5 @@
import { getIndexProps } from '@/lib/page-data';
import { getIndexProps, getFeaturedBoards } from '@/lib/page-data';
import { getHomeLayout } from '@/lib/home-layout';
import Hero from '@/components/home/Hero';
import QuickAccess from '@/components/home/QuickAccess';
import BoardSlots from '@/components/home/BoardSlots';
@@ -10,34 +11,52 @@ import LiveActivity from '@/components/home/LiveActivity';
export const dynamic = 'force-dynamic';
export default async function HomePage() {
const props = await getIndexProps();
const [props, layout] = await Promise.all([getIndexProps(), getHomeLayout()]);
const stats = (props as any).stats;
const recent = (props as any).recent ?? [];
const winners = (props as any).topWinners ?? [];
// boardSlots / hotBoards may have been customized; if so refetch with admin choice
const hotBoardsCfg = layout.find((l) => l.id === 'hotBoards' && l.enabled);
const slotsBoardsCfg = layout.find((l) => l.id === 'boardSlots' && l.enabled);
const customSlugs = Array.from(new Set([...(hotBoardsCfg?.boards ?? []), ...(slotsBoardsCfg?.boards ?? [])]));
const customFeatured = customSlugs.length ? await getFeaturedBoards(customSlugs) : props.featuredBoards;
const blocks: Record<string, React.ReactNode> = {
hero: <Hero key="hero" headlines={props.headlines} kickStatus={props.kickStatus} />,
statStrip: stats ? (
<StatStrip key="stats" members={stats.members} posts={stats.posts} comments={stats.comments}
visitsToday={stats.visitsToday} visitsTotal={stats.visitsTotal}
guarantees={stats.guarantees} muktiReports={stats.muktiReports} pointsCirculating={stats.pointsCirculating} />
) : null,
topWinners: winners.length > 0 ? <TopWinners key="winners" winners={winners} /> : null,
hotBoards: <HotBoardsCarousel key="hot" boards={hotBoardsCfg?.boards?.length ? customFeatured.filter((b) => hotBoardsCfg.boards!.includes(b.slug)) : customFeatured} />,
quickAccess: <QuickAccess key="quick" />,
boardSlots: <BoardSlots key="slots" boards={slotsBoardsCfg?.boards?.length ? customFeatured.filter((b) => slotsBoardsCfg.boards!.includes(b.slug)) : customFeatured} />,
liveActivity: <LiveActivity key="live" items={recent} />,
};
const sortedSections = layout.filter((s) => s.enabled);
// Render: liveActivity sits in right column when present, the rest stack
const liveActivity = sortedSections.find((s) => s.id === 'liveActivity') ? blocks.liveActivity : null;
const others = sortedSections.filter((s) => s.id !== 'liveActivity').map((s) => blocks[s.id]).filter(Boolean);
if (!liveActivity) {
return <div className="flex flex-col gap-6">{others}</div>;
}
// Place quickAccess+boardSlots in left column when liveActivity is enabled
const leftIds = new Set(['quickAccess', 'boardSlots']);
const leftCol = sortedSections.filter((s) => leftIds.has(s.id) && s.id !== 'liveActivity').map((s) => blocks[s.id]).filter(Boolean);
const topStack = sortedSections.filter((s) => !leftIds.has(s.id) && s.id !== 'liveActivity').map((s) => blocks[s.id]).filter(Boolean);
return (
<div className="flex flex-col gap-6">
<Hero headlines={props.headlines} kickStatus={props.kickStatus} />
{stats && (
<StatStrip
members={stats.members}
posts={stats.posts}
comments={stats.comments}
visitsToday={stats.visitsToday}
visitsTotal={stats.visitsTotal}
guarantees={stats.guarantees}
muktiReports={stats.muktiReports}
pointsCirculating={stats.pointsCirculating}
/>
)}
{winners.length > 0 && <TopWinners winners={winners} />}
<HotBoardsCarousel boards={props.featuredBoards} />
{topStack}
<div className="grid gap-6 lg:grid-cols-[1.6fr_1fr]">
<div className="flex flex-col gap-6">
<QuickAccess />
<BoardSlots boards={props.featuredBoards} />
</div>
<LiveActivity items={recent} />
<div className="flex flex-col gap-6">{leftCol}</div>
{liveActivity}
</div>
</div>
);
+45
View File
@@ -0,0 +1,45 @@
import { legacySql } from '@slot/db/legacy';
export type SectionId = 'hero' | 'statStrip' | 'topWinners' | 'hotBoards' | 'quickAccess' | 'boardSlots' | 'liveActivity';
export interface SectionConfig {
id: SectionId;
enabled: boolean;
order: number;
boards?: string[];
}
export const DEFAULT_LAYOUT: SectionConfig[] = [
{ id: 'hero', enabled: true, order: 1 },
{ id: 'statStrip', enabled: true, order: 2 },
{ id: 'topWinners', enabled: true, order: 3 },
{ id: 'hotBoards', enabled: true, order: 4, boards: ['free', 'review', 'mukti', 'humor', 'pick', 'lottery_ticket', 'guarantee', 'notice'] },
{ id: 'quickAccess', enabled: true, order: 5 },
{ id: 'boardSlots', enabled: true, order: 6, boards: ['free', 'review', 'mukti', 'humor', 'pick', 'lottery_ticket'] },
{ id: 'liveActivity', enabled: true, order: 7 },
];
export async function getHomeLayout(): Promise<SectionConfig[]> {
try {
const rows = await legacySql<{ value: { sections: SectionConfig[] } }[]>`
SELECT value FROM public.app_settings WHERE key = 'home_layout' LIMIT 1
`;
const cfg = rows[0]?.value?.sections;
if (Array.isArray(cfg) && cfg.length > 0) {
// Merge with defaults so newly-added sections still appear after admin saved an old config
const known = new Map(cfg.map((s) => [s.id, s]));
return DEFAULT_LAYOUT
.map((d) => ({ ...d, ...(known.get(d.id) ?? {}) }))
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99));
}
} catch { /* fall through */ }
return DEFAULT_LAYOUT;
}
export async function saveHomeLayout(sections: SectionConfig[]): Promise<void> {
await legacySql`
INSERT INTO public.app_settings (key, value)
VALUES ('home_layout', ${JSON.stringify({ sections })}::jsonb)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`.catch(() => {});
}
+3 -2
View File
@@ -219,9 +219,10 @@ export const QUICK_ACCESS = [
const FEATURED_BOARD_SLUGS = ['free', 'review', 'mukti', 'humor', 'pick', 'lottery_ticket', 'guarantee', 'notice'];
export async function getFeaturedBoards(): Promise<BoardSummary[]> {
export async function getFeaturedBoards(slugsOverride?: string[]): Promise<BoardSummary[]> {
const slugs = slugsOverride ?? FEATURED_BOARD_SLUGS;
const out: BoardSummary[] = [];
for (const slug of FEATURED_BOARD_SLUGS) {
for (const slug of slugs) {
try {
const meta = await legacySql<{ bo_subject: string }[]>`
SELECT bo_subject FROM inspection2.g5_board WHERE bo_table = ${slug}
+5 -1
View File
@@ -198,9 +198,13 @@ async function iteration(i) {
'/admin/shop/items', '/admin/shop/config', '/admin/shop/categories',
'/admin/shop/coupons', '/admin/shop/orders', '/admin/shop/sendcost', '/admin/shop/banners',
'/admin/eyoom/menu', '/admin/eyoom/yellowcard', '/admin/eyoom/managers', '/admin/eyoom/biz-info',
'/admin/sms/config', '/admin/sms/write',
'/admin/sms/config', '/admin/sms/write', '/admin/sms/history',
'/admin/plugin/sns', '/admin/plugin/recaptcha',
'/admin/roulette', '/admin/lottery/winners',
'/admin/eyoom/main-layout', '/admin/eyoom/tags', '/admin/eyoom/attendance',
'/admin/shop/brands', '/admin/shop/couponzone', '/admin/shop/buylist',
'/admin/members/visits', '/admin/members/poll',
'/admin/boards/popular', '/admin/seo',
];
for (const p of adminPaths) {
await check(`[REACT-ADMIN] GET ${p}`, async () => {