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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user