deploy first

This commit is contained in:
2025-09-10 04:31:53 +00:00
parent 2171d5e744
commit 364e91c47a
13 changed files with 235 additions and 135 deletions

View File

@@ -10,13 +10,14 @@ export default function AdminSettingsPage() {
const [saving, setSaving] = useState<boolean>(false); const [saving, setSaving] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [okMsg, setOkMsg] = useState<string>(""); const [okMsg, setOkMsg] = useState<string>("");
const [toast, setToast] = useState<null | { msg: string; type: 'ok' | 'err' }>(null);
const [handles, setHandles] = useState<Array<{ const [handles, setHandles] = useState<Array<{
id: string; id: string;
email: string;
handle: string; handle: string;
icon: string; icon: string;
isApproved: boolean; isApproved: boolean;
createtime: string; createtime: string;
costPerView?: number;
}>>([]); }>>([]);
const [loadingHandles, setLoadingHandles] = useState<boolean>(false); const [loadingHandles, setLoadingHandles] = useState<boolean>(false);
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set()); const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
@@ -42,20 +43,20 @@ export default function AdminSettingsPage() {
}; };
useEffect(() => { useEffect(() => {
(async () => { // (async () => {
setLoading(true); // setLoading(true);
setError(""); // setError("");
try { // try {
const res = await fetch('/api/cost_per_view', { cache: 'no-store' }); // const res = await fetch('/api/cost_per_view', { cache: 'no-store' });
const data = await res.json(); // const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? '로드 실패'); // if (!res.ok) throw new Error(data?.error ?? '로드 실패');
if (typeof data.value === 'number') setValue(String(data.value)); // if (typeof data.value === 'number') setValue(String(data.value));
} catch (e: any) { // } catch (e: any) {
setError(e?.message ?? '로드 실패'); // setError(e?.message ?? '로드 실패');
} finally { // } finally {
setLoading(false); // setLoading(false);
} // }
})(); // })();
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -97,23 +98,23 @@ export default function AdminSettingsPage() {
}; };
const onSave = async () => { const onSave = async () => {
setSaving(true); // setSaving(true);
setError(""); // setError("");
setOkMsg(""); // setOkMsg("");
try { // try {
const res = await fetch('/api/cost_per_view', { // const res = await fetch('/api/cost_per_view', {
method: 'POST', // method: 'POST',
headers: { 'Content-Type': 'application/json' }, // headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }), // body: JSON.stringify({ value }),
}); // });
const data = await res.json(); // const data = await res.json();
if (!res.ok || !data?.ok) throw new Error(data?.error ?? '저장 실패'); // if (!res.ok || !data?.ok) throw new Error(data?.error ?? '저장 실패');
setOkMsg('저장되었습니다'); // setOkMsg('저장되었습니다');
} catch (e: any) { // } catch (e: any) {
setError(e?.message ?? '저장 실패'); // setError(e?.message ?? '저장 실패');
} finally { // } finally {
setSaving(false); // setSaving(false);
} // }
}; };
const toggleApprove = async (id: string, approve: boolean) => { const toggleApprove = async (id: string, approve: boolean) => {
@@ -138,8 +139,18 @@ export default function AdminSettingsPage() {
} }
}; };
const showToast = (msg: string, type: 'ok' | 'err' = 'ok') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 2000);
};
return ( return (
<div className="w-full max-w-[900px] mx-auto p-4"> <div className="w-full max-w-[900px] mx-auto p-4 overflow-y-auto h-full">
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-md shadow-lg text-white ${toast.type === 'ok' ? 'bg-green-600' : 'bg-red-600'}`}>
{toast.msg}
</div>
)}
<div className="w-full flex justify-between mb-3"> <div className="w-full flex justify-between mb-3">
<button <button
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm" className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
@@ -150,7 +161,7 @@ export default function AdminSettingsPage() {
</div> </div>
<h1 className="text-2xl font-bold mb-4"> </h1> <h1 className="text-2xl font-bold mb-4"> </h1>
<div className="border-1 border-[#e6e9ef] rounded-lg bg-white p-4"> <div className="border-1 border-[#e6e9ef] rounded-lg bg-white p-4">
<div className="mb-2 font-semibold">Cost Per View</div> {/* <div className="mb-2 font-semibold">Cost Per View</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -170,12 +181,12 @@ export default function AdminSettingsPage() {
</div> </div>
{loading && <div className="text-sm text-[#848484] mt-2">불러오는 중...</div>} {loading && <div className="text-sm text-[#848484] mt-2">불러오는 중...</div>}
{error && <div className="text-sm text-red-600 mt-2">{error}</div>} {error && <div className="text-sm text-red-600 mt-2">{error}</div>}
{okMsg && <div className="text-sm text-green-600 mt-2">{okMsg}</div>} {okMsg && <div className="text-sm text-green-600 mt-2">{okMsg}</div>} */}
</div> </div>
<div className="border-1 border-[#e6e9ef] rounded-lg bg-white p-4 mt-6"> <div className="border-1 border-[#e6e9ef] rounded-lg bg-white p-4 mt-6">
<div className="mb-2 font-semibold flex items-center justify-between"> <div className="mb-2 font-semibold flex items-center justify-between">
<span>User Handle </span> <span>Handle </span>
<button <button
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm" className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
onClick={loadHandles} onClick={loadHandles}
@@ -185,18 +196,16 @@ export default function AdminSettingsPage() {
<table className="w-full min-w-[680px] table-fixed border-separate border-spacing-y-1"> <table className="w-full min-w-[680px] table-fixed border-separate border-spacing-y-1">
<colgroup> <colgroup>
<col className="w-[48px]" /> <col className="w-[48px]" />
<col className="w-[200px]" />
<col className="w-[220px]" /> <col className="w-[220px]" />
<col className="w-[120px]" /> <col className="w-[120px]" />
<col className="w-[160px]" /> <col className="w-[200px]" />
</colgroup> </colgroup>
<thead> <thead>
<tr className="h-[40px]"> <tr className="h-[40px]">
<th className="text-left"></th> <th className="text-left"></th>
<th className="text-left"></th> <th className="text-left"></th>
<th className="text-left"></th>
<th className="text-left"></th>
<th className="text-left"></th> <th className="text-left"></th>
<th className="text-left">CPV(/)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -212,25 +221,43 @@ export default function AdminSettingsPage() {
</div> </div>
</td> </td>
<td className="px-2 font-semibold">{h.handle}</td> <td className="px-2 font-semibold">{h.handle}</td>
<td className="px-2 text-sm text-[#555]">{h.email}</td> <td className="px-2 text-sm">{h.createtime?.split('T')[0]}</td>
<td className="px-2"> <td className="px-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`${h.isApproved ? '' : 'text-[#F94B37]'}`}>{h.isApproved ? '승인' : '미승인'}</span> <input
type="number"
step="0.1"
defaultValue={h.costPerView ?? 0}
className="border-1 border-border-pale rounded-md px-2 py-1 w-[120px]"
onChange={(e) => {
const v = Number(e.target.value);
setHandles(prev => prev.map(x => x.id === h.id ? { ...x, costPerView: v } : x));
}}
/>
<button <button
className="px-2 py-1 text-xs rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] disabled:opacity-60" className="px-2 py-1 text-xs rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] disabled:opacity-60"
onClick={() => toggleApprove(h.id, !h.isApproved)} onClick={async () => {
disabled={pendingIds.has(h.id)} const row = handles.find(x => x.id === h.id);
> const v = Number(row?.costPerView ?? 0);
{h.isApproved ? '미승인으로' : '승인'} const res = await fetch('/api/admin/handle/cpv', {
</button> method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: h.id, costPerView: v }),
});
if (res.ok) {
showToast('적용되었습니다', 'ok');
} else {
showToast('저장 실패', 'err');
}
}}
></button>
</div> </div>
</td> </td>
<td className="px-2 text-sm">{h.createtime?.split('T')[0]}</td>
</tr> </tr>
))} ))}
{handles.length === 0 && ( {handles.length === 0 && (
<tr> <tr>
<td colSpan={5} className="text-center py-4 text-[#848484]"> <td colSpan={4} className="text-center py-4 text-[#848484]">
{loadingHandles ? '불러오는 중...' : '데이터가 없습니다'} {loadingHandles ? '불러오는 중...' : '데이터가 없습니다'}
</td> </td>
</tr> </tr>

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'
import { PrismaClient } from '@/app/generated/prisma'
export async function POST(request: Request) {
const prisma = new PrismaClient()
try {
const body = await request.json().catch(() => ({})) as any
const id = String(body?.id || '')
const costPerView = Number(body?.costPerView)
if (!id || !Number.isFinite(costPerView)) {
return NextResponse.json({ error: 'invalid payload' }, { status: 400 })
}
await prisma.handle.update({ where: { id }, data: { costPerView } })
return NextResponse.json({ ok: true })
} catch (e) {
return NextResponse.json({ error: 'update failed' }, { status: 500 })
} finally {
try { await prisma.$disconnect() } catch {}
}
}

View File

@@ -1,29 +0,0 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@/app/generated/prisma';
export async function POST(request: Request) {
const prisma = new PrismaClient();
try {
const body = await request.json();
const id: string | undefined = body?.id;
const approve: boolean | undefined = body?.approve;
if (!id || typeof approve !== 'boolean') {
return NextResponse.json({ error: 'id와 approve(boolean)가 필요합니다' }, { status: 400 });
}
const updated = await prisma.userHandle.update({
where: { id },
data: { isApproved: approve },
select: { id: true, isApproved: true },
});
return NextResponse.json({ ok: true, item: updated });
} catch (e) {
console.error('POST /api/admin/user_handles/approve 오류:', e);
return NextResponse.json({ error: '업데이트 실패' }, { status: 500 });
} finally {
await prisma.$disconnect();
}
}

View File

@@ -1,14 +1,26 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { PrismaClient } from '@/app/generated/prisma'; import { PrismaClient } from '@/app/generated/prisma';
// 모든 Handle을 단순 목록으로 반환합니다.
// 관리자 UI 호환을 위해 필드 형태를 맞춥니다.
export async function GET() { export async function GET() {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
try { try {
const handles = await prisma.userHandle.findMany({ const handles = await prisma.handle.findMany({
orderBy: { createtime: 'desc' }, orderBy: { handle: 'asc' },
select: { id: true, email: true, handle: true, isApproved: true, createtime: true, icon: true } select: { id: true, handle: true, avatar: true, costPerView: true },
}); });
return NextResponse.json({ items: handles }); const nowIso = new Date().toISOString();
const items = handles.map(h => ({
id: h.id,
email: '',
handle: h.handle,
isApproved: true,
createtime: nowIso,
icon: h.avatar,
costPerView: Number(h.costPerView ?? 0),
}));
return NextResponse.json({ items });
} catch (e) { } catch (e) {
console.error('admin user_handles 오류:', e); console.error('admin user_handles 오류:', e);
return NextResponse.json({ error: '조회 실패' }, { status: 500 }); return NextResponse.json({ error: '조회 실패' }, { status: 500 });

View File

@@ -5,44 +5,29 @@ import { PrismaClient } from '@/app/generated/prisma';
export async function GET() { export async function GET() {
const session = await auth(); const session = await auth();
if (!session) { if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const email = session.user.email as string;
const email = session.user?.email as string | undefined;
if (!email) {
return NextResponse.json({ error: '세션 이메일을 찾을 수 없습니다' }, { status: 400 });
}
const prisma = new PrismaClient(); const prisma = new PrismaClient();
try { try {
// Admin: return all handles let rows;
if (email === 'wsx204@naver.com') { if (email === 'wsx204@naver.com') {
const all = await prisma.handle.findMany({ // 관리자: 전체 핸들
rows = await prisma.handle.findMany({
orderBy: { handle: 'asc' }, orderBy: { handle: 'asc' },
select: { id: true, handle: true, avatar: true } select: { id: true, handle: true, avatar: true },
}); });
const items = all.map(h => ({ } else {
id: h.id, // 일반 사용자: 자신의 핸들만
handle: h.handle, rows = await prisma.handle.findMany({
createtime: new Date().toISOString(), where: { users: { some: { email } } },
is_approved: false,
icon: h.avatar,
}));
return NextResponse.json({ items });
}
// Non-admin: handles linked to session user
const user = await prisma.user.findFirst({ where: { email }, select: { id: true } });
if (!user) {
return NextResponse.json({ items: [] });
}
const linked = await prisma.handle.findMany({
where: { users: { some: { id: user.id } } },
orderBy: { handle: 'asc' }, orderBy: { handle: 'asc' },
select: { id: true, handle: true, avatar: true } select: { id: true, handle: true, avatar: true },
}); });
const items = linked.map(h => ({ }
const items = rows.map(h => ({
id: h.id, id: h.id,
handle: h.handle, handle: h.handle,
createtime: new Date().toISOString(), createtime: new Date().toISOString(),

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server'
import { PrismaClient } from '@/app/generated/prisma'
import { auth } from '@/auth'
export async function GET(request: Request) {
const prisma = new PrismaClient()
try {
const session = await auth()
const { searchParams } = new URL(request.url)
const handleIdsParam = searchParams.get('handleIds')
let handleIds: string[] | null = null
if (handleIdsParam) {
handleIds = handleIdsParam.split(',').map(s => s.trim()).filter(Boolean)
}
if (!handleIds) {
if (!session?.user?.email) return NextResponse.json({ items: [] })
const myHandles = await prisma.handle.findMany({
where: { users: { some: { email: session.user.email } } },
select: { id: true },
})
handleIds = myHandles.map(h => h.id)
}
const items: Array<{ handleId: string, videoCount: number, views: number }> = []
for (const id of handleIds) {
const videoCount = await prisma.content.count({ where: { handleId: id } })
const agg = await prisma.contentDayView.aggregate({
_sum: { views: true },
where: { content: { handleId: id } },
})
items.push({ handleId: id, videoCount, views: Number(agg._sum.views ?? 0) })
}
return NextResponse.json({ items })
} catch (e) {
return NextResponse.json({ error: 'stats failed' }, { status: 500 })
} finally {
try { await prisma.$disconnect() } catch {}
}
}

View File

@@ -118,7 +118,8 @@ export async function GET(request: Request) {
cur.validViews += r.validViews || 0 cur.validViews += r.validViews || 0
cur.premiumViews += r.premiumViews || 0 cur.premiumViews += r.premiumViews || 0
cur.watchTime += r.watchTime || 0 cur.watchTime += r.watchTime || 0
cur.expectedRevenue += Math.max(0, Math.round((r.validViews || 0) * cpv)) const effViews = (r.validViews || 0) + (r.premiumViews || 0)
cur.expectedRevenue += Math.max(0, Math.round(effViews * cpv))
bucket.set(key, cur) bucket.set(key, cur)
} }
// 빈 버킷 채우기 (연속 타임라인) // 빈 버킷 채우기 (연속 타임라인)
@@ -189,7 +190,7 @@ export async function GET(request: Request) {
const premiumViews = g._sum?.premiumViews ?? 0 const premiumViews = g._sum?.premiumViews ?? 0
const watchTime = g._sum?.watchTime ?? 0 const watchTime = g._sum?.watchTime ?? 0
const cpv = typeof h?.costPerView === 'number' ? h!.costPerView! : 0 const cpv = typeof h?.costPerView === 'number' ? h!.costPerView! : 0
const expectedRevenue = Math.max(0, Math.round(validViews * cpv)) const expectedRevenue = Math.max(0, Math.round((validViews + premiumViews) * cpv))
return { return {
id: c?.id ?? g.contentId, id: c?.id ?? g.contentId,
subject: c?.subject ?? '', subject: c?.subject ?? '',

View File

@@ -389,18 +389,18 @@ export default function Page({user}: {user: any}) {
py-2 py-2
"> ">
<div className="order-1 col-[1/5] row-[1/2] flex flex-row"> <div className="order-1 col-[1/5] row-[1/2] flex flex-row">
<div className="grow flex flex-row items-center overflow-y-hidden overflow-x-auto mx-1"> <div className="grow flex flex-row items-center overflow-y-hidden overflow-x-auto mx-1 scrollbar-thin-x scrollbar-outside-x">
{myChannelList.length === 0 ? ( {myChannelList.length === 0 ? (
<div className=" h-[36px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold text-[#848484]"> <div className=" h-[32px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold text-[#848484]">
. .
</div> </div>
) : visibleChannelIds.size === 0 ? ( ) : visibleChannelIds.size === 0 ? (
<div className=" h-[36px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold text-[#F94B37]"> <div className=" h-[32px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold text-[#F94B37]">
. . . .
</div> </div>
) : ( ) : (
myChannelList.filter(c => visibleChannelIds.has(c.id)).map((channel, index) => ( myChannelList.filter(c => visibleChannelIds.has(c.id)).map((channel, index) => (
<div key={index} className=" h-[36px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold"> <div key={index} className=" h-[30px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold">
<div className="w-[24px] h-[24px] rounded-full border-1 border-[#e6e9ef] overflow-hidden mr-2"> <div className="w-[24px] h-[24px] rounded-full border-1 border-[#e6e9ef] overflow-hidden mr-2">
{channel.icon ? ( {channel.icon ? (
<img src={channel.icon} alt="channel icon" className="w-[24px] h-[24px] rounded-full" /> <img src={channel.icon} alt="channel icon" className="w-[24px] h-[24px] rounded-full" />
@@ -540,8 +540,8 @@ export default function Page({user}: {user: any}) {
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center">{formatNumberWithCommas(r.watchTime)}</td> <td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center">{formatNumberWithCommas(r.watchTime)}</td>
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center">{formatNumberWithCommas(r.views)}</td> <td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center">{formatNumberWithCommas(r.views)}</td>
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center">{formatNumberWithCommas(r.premiumViews)}</td> <td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center">{formatNumberWithCommas(r.premiumViews)}</td>
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center bg-[#FFF3F2]">{formatNumberWithCommas(r.validViews)}</td> <td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center ">{formatNumberWithCommas(r.validViews)}</td>
<td className=" border-[#e6e9ef] text-center border-r-1 border-b-1 border-t-1 rounded-r-lg whitespace-nowrap px-2 bg-[#FFF3F2]"> <td className=" border-[#e6e9ef] text-center border-r-1 border-b-1 border-t-1 rounded-r-lg whitespace-nowrap px-2 ">
<span className="inline-block min-w-[180px] text-center">{formatNumberWithCommas(Math.round(r.expectedRevenue))}</span> <span className="inline-block min-w-[180px] text-center">{formatNumberWithCommas(Math.round(r.expectedRevenue))}</span>
</td> </td>
</tr> </tr>

View File

@@ -63,7 +63,8 @@ const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
<span></span> <span></span>
</Link> </Link>
</li> </li>
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group"> {false && user?.email === 'wsx204@naver.com' && (<li></li>)}
{/* <li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
<Link href="/usr/3_jsmanage" className="flex items-center gap-2 p-2 group-hover:text-primary-normal" onClick={() => setIsOpen(false)}> <Link href="/usr/3_jsmanage" className="flex items-center gap-2 p-2 group-hover:text-primary-normal" onClick={() => setIsOpen(false)}>
<div className="w-[25px] h-[24px]"> <div className="w-[25px] h-[24px]">
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -73,7 +74,7 @@ const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
<span>정산관리</span> <span>정산관리</span>
</Link> </Link>
</li> </li> */}
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group"> <li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
<Link href="/usr/4_noticeboard" className="flex items-center gap-2 p-2 group-hover:text-primary-normal" onClick={() => setIsOpen(false)}> <Link href="/usr/4_noticeboard" className="flex items-center gap-2 p-2 group-hover:text-primary-normal" onClick={() => setIsOpen(false)}>
<div className="w-[25px] h-[24px] flex items-center justify-center"> <div className="w-[25px] h-[24px] flex items-center justify-center">
@@ -87,6 +88,16 @@ const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
</ul> </ul>
</div> </div>
<div className="flex-1 flex flex-col justify-end mb-10"> <div className="flex-1 flex flex-col justify-end mb-10">
{user?.email === 'wsx204@naver.com' && (
<div className="mb-4">
<Link href="/admin" className="flex items-center gap-2 p-2 border-1 border-[#e6e9ef] rounded-lg hover:bg-[#F0F0F0]" onClick={() => setIsOpen(false)}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L15 8L22 9L17 14L18 21L12 18L6 21L7 14L2 9L9 8L12 2Z" className="stroke-none fill-black"/>
</svg>
<span className="text-lg font-bold"></span>
</Link>
</div>
)}
<div className="grid grid-cols-[5px_44px_1fr_5px] grid-rows-[24px_20px_auto] gap-0.5"> <div className="grid grid-cols-[5px_44px_1fr_5px] grid-rows-[24px_20px_auto] gap-0.5">
<div className="col-[1/1] row-[1/3]"></div> <div className="col-[1/1] row-[1/3]"></div>

View File

@@ -52,6 +52,28 @@
} }
/* Thin horizontal scrollbar utility */
.scrollbar-thin-x {
scrollbar-width: thin; /* Firefox */
scrollbar-color: #c1c1c1 transparent;
}
.scrollbar-thin-x::-webkit-scrollbar {
height: 4px; /* Horizontal scrollbar thickness */
}
.scrollbar-thin-x::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin-x::-webkit-scrollbar-thumb {
background-color: #c1c1c1;
border-radius: 9999px;
}
/* Render horizontal scrollbar outside the element box (visually below) */
.scrollbar-outside-x {
padding-bottom: 8px; /* space for scrollbar */
margin-bottom: -8px; /* pull content height back */
}
.milkdown .ProseMirror { .milkdown .ProseMirror {
position: relative; /* 부모 요소에 상대 위치를 설정 */ position: relative; /* 부모 요소에 상대 위치를 설정 */
padding: 0px 0px 0px 80px !important; padding: 0px 0px 0px 80px !important;

View File

@@ -41,7 +41,7 @@ export default function Home() {
<div className="flex flex-col items-center justify-center gap-5"> <div className="flex flex-col items-center justify-center gap-5">
<div className="flex flex-col items-center justify-center gap-5"> <div className="flex flex-col items-center justify-center gap-5">
<div className="text-white text-2xl font-bold mb-3 justify-center items-center"> <div className="text-white text-2xl font-bold mb-3 justify-center items-center">
<Image src="/ever_logo.png" alt="logo" width={100} height={100} /> <Image src="/falogo2.png" alt="logo" width={200} height={200} />
</div> </div>
<div className="text-white text-4xl font-bold text-center mb-3"> <br/> </div> <div className="text-white text-4xl font-bold text-center mb-3"> <br/> </div>
{/* <div className="text-white text-md text-center mb-3">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi lobortis maximus</div> */} {/* <div className="text-white text-md text-center mb-3">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi lobortis maximus</div> */}

View File

@@ -29,6 +29,7 @@ export default function Page() {
is_approved: boolean; is_approved: boolean;
icon: string; icon: string;
}[]>([]); }[]>([]);
const [stats, setStats] = useState<Record<string, { videoCount: number; views: number }>>({});
const [registerCode, setRegisterCode] = useState<string>(""); const [registerCode, setRegisterCode] = useState<string>("");
@@ -86,12 +87,23 @@ export default function Page() {
const data = await resp.json(); const data = await resp.json();
setChannelList(data.items); setChannelList(data.items);
console.log('list_channel:', data); console.log('list_channel:', data);
// 채널별 통계 로드
const ids = (data.items || []).map((i: any) => i.id).filter(Boolean);
if (ids.length > 0) {
const sresp = await fetch(`/api/channel/stats?handleIds=${encodeURIComponent(ids.join(','))}`, { cache: 'no-store' });
const sdata = await sresp.json();
const map: Record<string, { videoCount: number; views: number }> = {};
for (const it of (sdata.items || [])) map[it.handleId] = { videoCount: it.videoCount, views: it.views };
setStats(map);
}
} catch (e) { } catch (e) {
console.error('list_channel 요청 에러:', e); console.error('list_channel 요청 에러:', e);
} }
} }
const delete_channel = async (handle: string) => { const delete_channel = async (handle: string) => {
alert("삭제 비활성화 관리자에게 문의하세요.");
return;
if (isLoading) return; if (isLoading) return;
if (!handle) return; if (!handle) return;
const ok = confirm(`정말 "${handle}" 채널 연결을 해제하시겠습니까?`); const ok = confirm(`정말 "${handle}" 채널 연결을 해제하시겠습니까?`);
@@ -139,25 +151,21 @@ export default function Page() {
<div className=" <div className="
border-1 border-[#e6e9ef] rounded-lg bg-white overflow-y-auto border-1 border-[#e6e9ef] rounded-lg bg-white overflow-y-auto
"> ">
<table className="w-full h-full border-separate border-spacing-y-1 table-fixed px-[10px]"> <table className="w-full h-full border-separate border-spacing-y-1 table-fixed">
<colgroup> <colgroup>
<col className="min-w-[250px] max-w-[300px]"/> <col className="min-w-[250px] max-w-[500px]"/>
<col className="w-[120px]"/> <col className="w-[120px]"/>
<col className="w-[80px]"/> <col className="w-[80px]"/>
<col className="w-[100px]"/> <col className="w-[100px]"/>
<col className="w-[100px]"/> <col className="w-[100px]"/>
<col className="w-[110px]"/>
<col className="w-[90px]"/>
</colgroup> </colgroup>
<thead> <thead>
<tr className="sticky top-0 bg-white h-[49px] "> <tr className="sticky top-0 bg-white h-[49px] ">
<th className="border-b-1 right-border border-[#e6e9ef] "> </th> <th className="border-b-1 right-border border-[#e6e9ef] "> </th>
<th className="border-b-1 right-border border-[#e6e9ef] "> </th> <th className="border-b-1 right-border border-[#e6e9ef] "> </th>
<th className="border-b-1 right-border border-[#e6e9ef] "></th>
<th className="border-b-1 right-border border-[#e6e9ef] "></th> <th className="border-b-1 right-border border-[#e6e9ef] "></th>
<th className="border-b-1 right-border border-[#e6e9ef] "></th> <th className="border-b-1 right-border border-[#e6e9ef] "></th>
<th className="border-b-1 right-border border-[#e6e9ef] "></th> <th className="border-b-1 border-[#e6e9ef] rounded-tr-lg "></th>
<th className="border-b-1 border-[#e6e9ef] "></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -176,13 +184,11 @@ export default function Page() {
</div> </div>
</td> </td>
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center whitespace-nowrap overflow-hidden">{channel.createtime.split("T")[0]}</td> <td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center whitespace-nowrap overflow-hidden">{channel.createtime.split("T")[0]}</td>
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center whitespace-nowrap overflow-hidden">{channel.is_approved? "승인" : "미승인"}</td> <td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center">{stats[channel.id]?.videoCount ?? '-'}</td>
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center"> - </td> <td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center">{stats[channel.id]?.views ?? '-'}</td>
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center"> - </td> <td className="border-b-1 border-t-1 border-r-1 border-[#e6e9ef] rounded-r-lg text-center">
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center"> - </td>
<td className="border-b-1 border-t-1 border-[#e6e9ef] border-r-1 rounded-r-lg text-center">
<button <button
className={`px-3 py-1 rounded-md ${isLoading ? 'bg-gray-300 cursor-not-allowed' : 'bg-red-500 hover:bg-red-600'} text-white`} className={`px-3 py-1 rounded-md bg-gray-300 text-white cursor-not-allowed' : 'bg-red-500 hover:bg-red-600'} text-white`}
onClick={() => { if (!isLoading) delete_channel(channel.handle); }} onClick={() => { if (!isLoading) delete_channel(channel.handle); }}
> >