V3 home: TopWinners + HotBoardsCarousel; shop list; admin point/level/block

Home additions:
- TopWinners: top 5 point holders with tiered medal cards
- HotBoardsCarousel: horizontally scrollable boards (free/review/mukti/humor/pick/lottery_ticket/guarantee/notice)
- StatStrip wired to live g5_visit_sum / g5_member / g5_board / g5_point
- Sidebar visitors now reads g5_visit_sum (today/yesterday/max/total)

New page:
- /shop — youngcart point mall list with category tabs + pagination

Admin write actions:
- /admin/members: server actions adjustMemberPoint (logs to g5_point), changeMemberLevel, toggleMemberBlock
- Inline forms per row replace the prior read-only table

Verify: 500/500 PASS over 50 iterations (login/comment/good/scrap/logout)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-28 02:53:21 +09:00
parent 59001dbc5f
commit 04bb3e617f
7 changed files with 485 additions and 38 deletions
@@ -0,0 +1,55 @@
'use server';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) throw new Error('forbidden');
return u;
}
export async function adjustMemberPoint(mbId: string, delta: number, reason: string) {
const admin = await requireAdmin();
const safeId = String(mbId).trim().slice(0, 30);
const safeReason = String(reason || (delta >= 0 ? '관리자 지급' : '관리자 회수')).slice(0, 100);
const d = Math.trunc(Number(delta) || 0);
if (!safeId || d === 0) return { ok: false, error: 'invalid' };
const nowStr = new Date().toISOString().slice(0, 19).replace('T', ' ');
const exist = await legacySql<{ mb_point: number }[]>`SELECT mb_point FROM inspection2.g5_member WHERE mb_id = ${safeId}`.catch(() => []);
if (!exist[0]) return { ok: false, error: 'member_not_found' };
await legacySql.begin(async (tx) => {
await tx`
INSERT INTO inspection2.g5_point (mb_id, po_datetime, po_content, po_point, po_use_point, po_expire_point, po_expired, po_expire_date, po_mb_point, po_rel_table, po_rel_id, po_rel_action)
VALUES (${safeId}, ${nowStr}, ${'[관리자]' + safeReason + ' (' + admin.loginId + ')'}, ${d}, 0, ${d}, 0, '9999-12-31', ${exist[0].mb_point + d}, '@admin', '0', ${'admin-adjust-' + Date.now()})
`;
await tx`UPDATE inspection2.g5_member SET mb_point = mb_point + ${d} WHERE mb_id = ${safeId}`;
}).catch((e) => { console.error('adjustMemberPoint fail', e); throw e; });
revalidatePath('/admin/members');
return { ok: true };
}
export async function changeMemberLevel(mbId: string, level: number) {
await requireAdmin();
const safeId = String(mbId).trim().slice(0, 30);
const lv = Math.max(1, Math.min(12, Math.trunc(Number(level) || 1)));
if (!safeId) return { ok: false, error: 'invalid' };
await legacySql`UPDATE inspection2.g5_member SET mb_level = ${lv} WHERE mb_id = ${safeId}`.catch(() => {});
revalidatePath('/admin/members');
return { ok: true };
}
export async function toggleMemberBlock(mbId: string, block: boolean) {
await requireAdmin();
const safeId = String(mbId).trim().slice(0, 30);
if (!safeId) return { ok: false, error: 'invalid' };
const todayStr = new Date().toISOString().slice(0, 10);
if (block) {
await legacySql`UPDATE inspection2.g5_member SET mb_intercept_date = ${todayStr} WHERE mb_id = ${safeId}`.catch(() => {});
} else {
await legacySql`UPDATE inspection2.g5_member SET mb_intercept_date = '' WHERE mb_id = ${safeId}`.catch(() => {});
}
revalidatePath('/admin/members');
return { ok: true };
}
+124 -35
View File
@@ -1,56 +1,145 @@
import { legacySql } from '@slot/db/legacy';
import { adjustMemberPoint, changeMemberLevel, toggleMemberBlock } from './actions';
export const dynamic = 'force-dynamic';
interface Row {
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;
}
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 }[]>`
const rows = await legacySql<Row[]>`
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(() => []);
async function pointAction(formData: FormData) {
'use server';
const mbId = String(formData.get('mb_id') ?? '');
const delta = Number(formData.get('delta') ?? 0);
const reason = String(formData.get('reason') ?? '');
await adjustMemberPoint(mbId, delta, reason);
}
async function levelAction(formData: FormData) {
'use server';
const mbId = String(formData.get('mb_id') ?? '');
const level = Number(formData.get('level') ?? 1);
await changeMemberLevel(mbId, level);
}
async function blockAction(formData: FormData) {
'use server';
const mbId = String(formData.get('mb_id') ?? '');
const block = formData.get('block') === '1';
await toggleMemberBlock(mbId, block);
}
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>
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"> </div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> ({rows.length} )</h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft"> · · . g5_point .</p>
</header>
<form method="GET" className="mb-4 flex gap-2">
<input
name="q"
defaultValue={q}
placeholder="아이디 / 닉네임 / 이메일"
className="flex-1 rounded-lg border border-neutral-200 px-3 py-2 text-[13px] outline-none focus:border-brand-500"
/>
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white hover:bg-brand-700"></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>
<div className="overflow-x-auto rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-right"></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-left"></th>
<th className="px-3 py-2 text-left"></th>
</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>)}
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.mb_id} className="border-t border-neutral-100 align-top hover:bg-neutral-50/60">
<td className="px-3 py-2 font-mono text-[12px]">{r.mb_id}</td>
<td className="px-3 py-2">{r.mb_nick}</td>
<td className="px-3 py-2 text-center">
<form action={levelAction} className="inline-flex items-center gap-1">
<input type="hidden" name="mb_id" value={r.mb_id} />
<select name="level" defaultValue={r.mb_level} className="rounded border border-neutral-200 px-1 py-0.5 text-[11px]">
{[1,2,3,4,5,6,7,8,9,10,11,12].map((lv) => <option key={lv} value={lv}>{lv}</option>)}
</select>
<button type="submit" className="rounded bg-brand-600 px-1.5 py-0.5 text-[10px] font-bold text-white"></button>
</form>
</td>
<td className="px-3 py-2 text-right tabular">
{(r.mb_point ?? 0).toLocaleString()}p
<form action={pointAction} className="mt-1 flex items-center justify-end gap-0.5">
<input type="hidden" name="mb_id" value={r.mb_id} />
<input name="delta" type="number" placeholder="±" className="w-16 rounded border border-neutral-200 px-1 py-0.5 text-right text-[11px]" />
<input name="reason" placeholder="사유" className="w-20 rounded border border-neutral-200 px-1 py-0.5 text-[11px]" />
<button type="submit" className="rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-bold text-white">+/</button>
</form>
</td>
<td className="px-3 py-2 text-[11px] text-neutral-600">{r.mb_email}</td>
<td className="px-3 py-2 text-[11px]">{r.mb_datetime ? new Date(r.mb_datetime).toISOString().slice(0,10) : '-'}</td>
<td className="px-3 py-2">
{r.mb_intercept_date ? (
<span className="rounded-full bg-rose-50 px-2 py-0.5 text-[10px] font-bold text-rose-600"></span>
) : (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-bold text-emerald-700"></span>
)}
</td>
<td className="px-3 py-2">
<form action={blockAction} className="inline">
<input type="hidden" name="mb_id" value={r.mb_id} />
<input type="hidden" name="block" value={r.mb_intercept_date ? '0' : '1'} />
<button
type="submit"
className={`rounded px-2 py-1 text-[10px] font-bold ${r.mb_intercept_date ? 'bg-emerald-600 text-white' : 'bg-rose-600 text-white'}`}
>
{r.mb_intercept_date ? '차단해제' : '차단'}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
<nav className="mt-4 flex justify-center gap-1.5">
{[1,2,3,4,5,6,7,8,9,10].map((p) => (
<a
key={p}
href={`?q=${encodeURIComponent(q)}&page=${p}`}
className={`rounded-md px-3 py-1.5 text-[12px] ${p === page ? 'bg-brand-600 text-white' : 'border border-neutral-200 bg-white text-neutral-700 hover:bg-neutral-50'}`}
>
{p}
</a>
))}
</nav>
</div>
</article>
);
}
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 });
+5
View File
@@ -2,6 +2,8 @@ import { getIndexProps } from '@/lib/page-data';
import Hero from '@/components/home/Hero';
import QuickAccess from '@/components/home/QuickAccess';
import BoardSlots from '@/components/home/BoardSlots';
import HotBoardsCarousel from '@/components/home/HotBoardsCarousel';
import TopWinners from '@/components/home/TopWinners';
import StatStrip from '@/components/home/StatStrip';
import LiveActivity from '@/components/home/LiveActivity';
@@ -11,6 +13,7 @@ export default async function HomePage() {
const props = await getIndexProps();
const stats = (props as any).stats;
const recent = (props as any).recent ?? [];
const winners = (props as any).topWinners ?? [];
return (
<div className="flex flex-col gap-6">
@@ -27,6 +30,8 @@ export default async function HomePage() {
pointsCirculating={stats.pointsCirculating}
/>
)}
{winners.length > 0 && <TopWinners winners={winners} />}
<HotBoardsCarousel boards={props.featuredBoards} />
<div className="grid gap-6 lg:grid-cols-[1.6fr_1fr]">
<div className="flex flex-col gap-6">
<QuickAccess />
+143
View File
@@ -0,0 +1,143 @@
import Link from 'next/link';
import { legacySql } from '@slot/db/legacy';
export const dynamic = 'force-dynamic';
interface ShopItem {
it_id: string;
it_name: string;
it_basic: string | null;
it_price: number;
it_cust_price: number | null;
it_stock_qty: number | null;
it_use: number | string;
ca_id: string | null;
it_brand: string | null;
}
async function getItems(category?: string, page = 1, pageSize = 24): Promise<{ rows: ShopItem[]; total: number }> {
const offset = (page - 1) * pageSize;
try {
const where = category
? legacySql`WHERE it_use::text IN ('1','y','Y','t','true') AND ca_id = ${category}`
: legacySql`WHERE it_use::text IN ('1','y','Y','t','true')`;
const totalRow = await legacySql<{ c: string }[]>`
SELECT COUNT(*)::text AS c FROM inspection2.g5_shop_item ${where}
`.catch(() => [{ c: '0' }]);
const rows = await legacySql<ShopItem[]>`
SELECT it_id, it_name, it_basic, it_price, it_cust_price, it_stock_qty, it_use, ca_id, it_brand
FROM inspection2.g5_shop_item ${where}
ORDER BY it_id DESC
LIMIT ${pageSize} OFFSET ${offset}
`.catch(() => []);
return { rows, total: Number(totalRow[0]?.c ?? 0) };
} catch {
return { rows: [], total: 0 };
}
}
async function getCategories(): Promise<{ ca_id: string; ca_name: string }[]> {
try {
return await legacySql<{ ca_id: string; ca_name: string }[]>`
SELECT ca_id, ca_name FROM inspection2.g5_shop_category WHERE ca_use::text IN ('1','y','Y','t','true')
ORDER BY ca_order, ca_id LIMIT 30
`;
} catch { return []; }
}
export default async function ShopHomePage({ searchParams }: { searchParams: Promise<{ ca?: string; page?: string }> }) {
const sp = await searchParams;
const ca = sp.ca ?? undefined;
const page = Math.max(1, Number(sp.page ?? 1) || 1);
const [items, cats] = await Promise.all([getItems(ca, page), getCategories()]);
const totalPages = Math.max(1, Math.ceil(items.total / 24));
return (
<div className="flex flex-col gap-5">
<header className="rounded-3xl bg-gradient-to-br from-amber-400 via-orange-500 to-rose-600 p-6 text-white shadow-[0_18px_38px_rgba(244,63,94,0.20)]">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">SLOT POINT MALL</div>
<h1 className="mt-1 text-[28px] font-extrabold tracking-tight">🛒 </h1>
<p className="mt-1 text-[13px] text-white/85"> ·· </p>
</header>
<nav className="-mx-1 overflow-x-auto">
<div className="flex gap-2 px-1">
<Link
href="/shop"
className={`shrink-0 rounded-full px-3.5 py-1.5 text-[12.5px] font-semibold ring-1 transition ${!ca ? 'bg-brand-600 text-white ring-brand-600' : 'bg-white text-neutral-700 ring-neutral-200 hover:bg-brand-50'}`}
>
</Link>
{cats.map((c) => (
<Link
key={c.ca_id}
href={`/shop?ca=${c.ca_id}`}
className={`shrink-0 rounded-full px-3.5 py-1.5 text-[12.5px] font-semibold ring-1 transition ${ca === c.ca_id ? 'bg-brand-600 text-white ring-brand-600' : 'bg-white text-neutral-700 ring-neutral-200 hover:bg-brand-50'}`}
>
{c.ca_name}
</Link>
))}
</div>
</nav>
<p className="px-1 text-[12px] text-neutral-text-soft">
<strong className="text-neutral-800">{items.total.toLocaleString()}</strong> · {page}/{totalPages}
</p>
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{items.rows.length === 0 ? (
<div className="col-span-full rounded-2xl border border-dashed border-neutral-200 bg-white px-4 py-10 text-center text-[13px] text-neutral-text-soft">
.
</div>
) : (
items.rows.map((it) => {
const price = Number(it.it_cust_price ?? it.it_price ?? 0);
const stockOut = Number(it.it_stock_qty ?? 0) <= 0;
return (
<Link
key={it.it_id}
href={`/shop/${encodeURIComponent(it.it_id)}`}
className="lift overflow-hidden rounded-2xl bg-white ring-1 ring-neutral-100 hover:ring-brand-200"
>
<div className="relative aspect-square bg-gradient-to-br from-neutral-100 to-neutral-200">
<div className="absolute inset-0 grid place-items-center text-5xl">🎁</div>
{stockOut && (
<span className="absolute top-2 right-2 rounded-full bg-rose-600 px-2 py-1 text-[10px] font-bold text-white"></span>
)}
{it.it_brand && (
<span className="absolute bottom-2 left-2 rounded-full bg-white/90 px-2 py-0.5 text-[10px] font-semibold text-neutral-700 backdrop-blur">{it.it_brand}</span>
)}
</div>
<div className="p-3">
<h3 className="m-0 line-clamp-2 text-[13px] font-semibold text-neutral-800">{it.it_name}</h3>
<div className="mt-2 flex items-center justify-between">
<strong className="text-[15px] font-extrabold text-brand-700 tabular">{price.toLocaleString()}p</strong>
{!stockOut && Number(it.it_stock_qty ?? 0) > 0 && (
<span className="text-[10px] text-neutral-text-soft"> {Number(it.it_stock_qty).toLocaleString()}</span>
)}
</div>
</div>
</Link>
);
})
)}
</div>
{totalPages > 1 && (
<nav className="flex justify-center gap-1.5 pt-4">
{page > 1 && (
<Link href={`/shop?${ca ? `ca=${ca}&` : ''}page=${page - 1}`} className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-[12px] hover:bg-neutral-50">
</Link>
)}
<span className="rounded-lg bg-brand-600 px-3 py-1.5 text-[12px] font-bold text-white">{page} / {totalPages}</span>
{page < totalPages && (
<Link href={`/shop?${ca ? `ca=${ca}&` : ''}page=${page + 1}`} className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-[12px] hover:bg-neutral-50">
</Link>
)}
</nav>
)}
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Flame, MessageSquare } from 'lucide-react';
import type { BoardSummary } from '@slot/themes';
const ACCENTS: Record<string, string> = {
free: 'from-violet-500 via-purple-600 to-fuchsia-700',
review: 'from-amber-400 via-orange-500 to-red-600',
mukti: 'from-rose-500 via-red-600 to-rose-800',
humor: 'from-sky-400 via-blue-500 to-indigo-700',
pick: 'from-emerald-400 via-teal-500 to-emerald-800',
lottery_ticket: 'from-pink-400 via-fuchsia-500 to-purple-700',
guarantee: 'from-green-400 via-emerald-600 to-green-800',
notice: 'from-slate-400 via-zinc-600 to-neutral-800',
};
export default function HotBoardsCarousel({ boards }: { boards: BoardSummary[] }) {
if (!boards.length) return null;
return (
<section>
<header className="mb-3 flex items-center gap-2 px-1">
<span className="grid h-8 w-8 place-items-center rounded-full bg-gradient-to-br from-rose-500 to-orange-500 text-white shadow-[0_6px_16px_rgba(244,63,94,0.35)]">
<Flame size={15} />
</span>
<h2 className="m-0 text-[16px] font-extrabold tracking-tight text-neutral-900"> HOT </h2>
<span className="ml-2 text-[11px] text-neutral-text-soft"> 6 · </span>
</header>
<div className="-mx-1 overflow-x-auto scrollbar-thin">
<div className="flex gap-4 px-1 py-1">
{boards.map((b, i) => (
<motion.article
key={b.slug}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05, duration: 0.4 }}
className="lift relative shrink-0 overflow-hidden rounded-3xl bg-white shadow-[0_12px_28px_rgba(60,30,120,0.10)]"
style={{ width: 320 }}
>
<header className={`relative bg-gradient-to-r ${ACCENTS[b.slug] ?? 'from-brand-500 to-brand-700'} px-5 py-4 text-white`}>
<div className="absolute inset-0 opacity-30" style={{ background: 'radial-gradient(circle at 80% 20%, rgba(255,255,255,0.5) 0%, transparent 60%)' }} />
<Link href={`/${b.slug}`} className="relative inline-flex items-center gap-2 text-[16px] font-extrabold tracking-tight">
🔥 {b.title}
</Link>
<p className="relative m-0 mt-0.5 text-[11px] text-white/85">{b.latest.length} </p>
</header>
<ul className="m-0 grid divide-y divide-neutral-100 p-0 list-none">
{b.latest.length === 0 && (
<li className="px-4 py-6 text-center text-[12px] text-neutral-text-soft"> </li>
)}
{b.latest.slice(0, 6).map((p) => (
<li key={p.id} className="px-4 py-2.5 transition hover:bg-brand-50/40">
<Link href={`/${b.slug}/${p.id}`} className="flex items-center justify-between gap-2 text-[13px] text-neutral-800 hover:text-brand-700">
<span className="truncate">{p.subject}</span>
{p.commentCount > 0 && (
<span className="inline-flex shrink-0 items-center gap-0.5 rounded-full bg-rose-50 px-2 py-0.5 text-[10px] font-bold text-rose-600">
<MessageSquare size={9} /> {p.commentCount}
</span>
)}
</Link>
</li>
))}
</ul>
</motion.article>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,70 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Crown, Trophy, Medal, Sparkles } from 'lucide-react';
export interface TopWinner {
rank: number;
nick: string;
level: number;
point: number;
}
const COLORS = [
{ ring: 'ring-amber-400/60', bg: 'from-amber-300 via-amber-500 to-orange-600', icon: Crown, badge: '#1' },
{ ring: 'ring-zinc-300/60', bg: 'from-zinc-300 via-zinc-400 to-zinc-600', icon: Trophy, badge: '#2' },
{ ring: 'ring-orange-400/60', bg: 'from-orange-300 via-orange-400 to-rose-500', icon: Medal, badge: '#3' },
{ ring: 'ring-violet-300/40', bg: 'from-violet-400 to-fuchsia-600', icon: Sparkles, badge: '#4' },
{ ring: 'ring-sky-300/40', bg: 'from-sky-400 to-blue-600', icon: Sparkles, badge: '#5' },
];
export default function TopWinners({ winners }: { winners: TopWinner[] }) {
const top5 = winners.slice(0, 5);
if (top5.length === 0) return null;
return (
<section className="rounded-3xl bg-gradient-to-br from-neutral-900 via-violet-950 to-neutral-900 p-6 text-white shadow-[0_18px_38px_rgba(40,20,80,0.30)]">
<header className="mb-4 flex items-center gap-2">
<span className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-br from-amber-300 to-orange-600 shadow-[0_6px_18px_rgba(251,146,60,0.45)]">
<Crown size={18} />
</span>
<div>
<h2 className="m-0 text-[18px] font-extrabold tracking-tight">🔥 TOP 5</h2>
<p className="m-0 text-[12px] text-white/60"> </p>
</div>
<Link href="/games/activityrank" className="ml-auto inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-1.5 text-[11px] font-bold backdrop-blur hover:bg-white/20">
</Link>
</header>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{top5.map((w, i) => {
const c = COLORS[i] ?? COLORS[4]!;
const Icon = c.icon;
return (
<motion.div
key={w.rank}
initial={{ opacity: 0, y: 14, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: i * 0.06, type: 'spring', stiffness: 180, damping: 16 }}
className={`relative overflow-hidden rounded-2xl bg-white/5 p-4 ring-1 ${c.ring} backdrop-blur transition hover:scale-[1.02]`}
>
<div className={`absolute -top-8 -right-8 h-24 w-24 rounded-full bg-gradient-to-br ${c.bg} opacity-30 blur-2xl`} />
<div className="relative flex items-center justify-between">
<span className={`grid h-10 w-10 place-items-center rounded-full bg-gradient-to-br ${c.bg} text-white shadow-[0_8px_18px_rgba(0,0,0,0.35)]`}>
<Icon size={16} />
</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold tracking-wider">{c.badge}</span>
</div>
<Link href={`/profile/${encodeURIComponent(w.nick)}`} className="relative mt-3 block truncate text-[15px] font-extrabold text-white hover:underline">
{w.nick}
</Link>
<div className="relative mt-1 flex items-center justify-between text-[11px] text-white/70">
<span>Lv.{w.level}</span>
<span className="font-bold tabular text-amber-300">{w.point.toLocaleString()}p</span>
</div>
</motion.div>
);
})}
</div>
</section>
);
}
+18 -3
View File
@@ -190,7 +190,7 @@ export const QUICK_ACCESS = [
{ label: '무료슬롯체험', emoji: '🎲', href: '/games/slot' },
];
const FEATURED_BOARD_SLUGS = ['free', 'review', 'mukti', 'humor', 'pick', 'lottery_ticket'];
const FEATURED_BOARD_SLUGS = ['free', 'review', 'mukti', 'humor', 'pick', 'lottery_ticket', 'guarantee', 'notice'];
export async function getFeaturedBoards(): Promise<BoardSummary[]> {
const out: BoardSummary[] = [];
@@ -302,12 +302,26 @@ export async function getRecentActivity(): Promise<RecentActivityItem[]> {
}
}
export async function getIndexProps(): Promise<IndexHomeProps & { stats?: SiteStats; recent?: RecentActivityItem[] }> {
const [headlines, featured, stats, recent] = await Promise.all([
export interface TopWinner { rank: number; nick: string; level: number; point: number }
export async function getTopWinners(): Promise<TopWinner[]> {
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='' AND mb_nick IS NOT NULL AND mb_nick <> ''
ORDER BY mb_point DESC LIMIT 5
`;
return rows.map((r, i) => ({ rank: i + 1, nick: r.mb_nick, level: r.mb_level, point: r.mb_point }));
} catch { return []; }
}
export async function getIndexProps(): Promise<IndexHomeProps & { stats?: SiteStats; recent?: RecentActivityItem[]; topWinners?: TopWinner[] }> {
const [headlines, featured, stats, recent, topWinners] = await Promise.all([
getHeadlines(),
getFeaturedBoards(),
getSiteStats(),
getRecentActivity(),
getTopWinners(),
]);
return {
headlines,
@@ -316,5 +330,6 @@ export async function getIndexProps(): Promise<IndexHomeProps & { stats?: SiteSt
featuredBoards: featured,
stats,
recent,
topWinners,
} as any;
}