deploy first
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
22
app/api/admin/handle/cpv/route.ts
Normal file
22
app/api/admin/handle/cpv/route.ts
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 일반 사용자: 자신의 핸들만
|
||||||
|
rows = await prisma.handle.findMany({
|
||||||
|
where: { users: { some: { email } } },
|
||||||
|
orderBy: { handle: 'asc' },
|
||||||
|
select: { id: true, handle: true, avatar: true },
|
||||||
});
|
});
|
||||||
const items = all.map(h => ({
|
|
||||||
id: h.id,
|
|
||||||
handle: h.handle,
|
|
||||||
createtime: new Date().toISOString(),
|
|
||||||
is_approved: false,
|
|
||||||
icon: h.avatar,
|
|
||||||
}));
|
|
||||||
return NextResponse.json({ items });
|
|
||||||
}
|
}
|
||||||
|
const items = rows.map(h => ({
|
||||||
// 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' },
|
|
||||||
select: { id: true, handle: true, avatar: true }
|
|
||||||
});
|
|
||||||
const items = linked.map(h => ({
|
|
||||||
id: h.id,
|
id: h.id,
|
||||||
handle: h.handle,
|
handle: h.handle,
|
||||||
createtime: new Date().toISOString(),
|
createtime: new Date().toISOString(),
|
||||||
|
|||||||
43
app/api/channel/stats/route.ts
Normal file
43
app/api/channel/stats/route.ts
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 ?? '',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> */}
|
||||||
|
|||||||
@@ -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); }}
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
|
|||||||
Reference in New Issue
Block a user