diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 53b98b0..9cbc468 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -10,13 +10,14 @@ export default function AdminSettingsPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [okMsg, setOkMsg] = useState(""); + const [toast, setToast] = useState(null); const [handles, setHandles] = useState>([]); const [loadingHandles, setLoadingHandles] = useState(false); const [pendingIds, setPendingIds] = useState>(new Set()); @@ -42,20 +43,20 @@ export default function AdminSettingsPage() { }; useEffect(() => { - (async () => { - setLoading(true); - setError(""); - try { - const res = await fetch('/api/cost_per_view', { cache: 'no-store' }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? '로드 실패'); - if (typeof data.value === 'number') setValue(String(data.value)); - } catch (e: any) { - setError(e?.message ?? '로드 실패'); - } finally { - setLoading(false); - } - })(); + // (async () => { + // setLoading(true); + // setError(""); + // try { + // const res = await fetch('/api/cost_per_view', { cache: 'no-store' }); + // const data = await res.json(); + // if (!res.ok) throw new Error(data?.error ?? '로드 실패'); + // if (typeof data.value === 'number') setValue(String(data.value)); + // } catch (e: any) { + // setError(e?.message ?? '로드 실패'); + // } finally { + // setLoading(false); + // } + // })(); }, []); useEffect(() => { @@ -97,23 +98,23 @@ export default function AdminSettingsPage() { }; const onSave = async () => { - setSaving(true); - setError(""); - setOkMsg(""); - try { - const res = await fetch('/api/cost_per_view', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value }), - }); - const data = await res.json(); - if (!res.ok || !data?.ok) throw new Error(data?.error ?? '저장 실패'); - setOkMsg('저장되었습니다'); - } catch (e: any) { - setError(e?.message ?? '저장 실패'); - } finally { - setSaving(false); - } + // setSaving(true); + // setError(""); + // setOkMsg(""); + // try { + // const res = await fetch('/api/cost_per_view', { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ value }), + // }); + // const data = await res.json(); + // if (!res.ok || !data?.ok) throw new Error(data?.error ?? '저장 실패'); + // setOkMsg('저장되었습니다'); + // } catch (e: any) { + // setError(e?.message ?? '저장 실패'); + // } finally { + // setSaving(false); + // } }; 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 ( -
+
+ {toast && ( +
+ {toast.msg} +
+ )}
{h.handle} - {h.email} + {h.createtime?.split('T')[0]}
- {h.isApproved ? '승인' : '미승인'} + { + const v = Number(e.target.value); + setHandles(prev => prev.map(x => x.id === h.id ? { ...x, costPerView: v } : x)); + }} + /> + onClick={async () => { + const row = handles.find(x => x.id === h.id); + const v = Number(row?.costPerView ?? 0); + const res = await fetch('/api/admin/handle/cpv', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: h.id, costPerView: v }), + }); + if (res.ok) { + showToast('적용되었습니다', 'ok'); + } else { + showToast('저장 실패', 'err'); + } + }} + >적용
- {h.createtime?.split('T')[0]} ))} {handles.length === 0 && ( - + {loadingHandles ? '불러오는 중...' : '데이터가 없습니다'} diff --git a/app/api/admin/handle/cpv/route.ts b/app/api/admin/handle/cpv/route.ts new file mode 100644 index 0000000..72d084d --- /dev/null +++ b/app/api/admin/handle/cpv/route.ts @@ -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 {} + } +} + + diff --git a/app/api/admin/user_handles/approve/route.ts b/app/api/admin/user_handles/approve/route.ts deleted file mode 100644 index a06063a..0000000 --- a/app/api/admin/user_handles/approve/route.ts +++ /dev/null @@ -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(); - } -} - - diff --git a/app/api/admin/user_handles/route.ts b/app/api/admin/user_handles/route.ts index 7152439..08fcf28 100644 --- a/app/api/admin/user_handles/route.ts +++ b/app/api/admin/user_handles/route.ts @@ -1,14 +1,26 @@ import { NextResponse } from 'next/server'; import { PrismaClient } from '@/app/generated/prisma'; +// 모든 Handle을 단순 목록으로 반환합니다. +// 관리자 UI 호환을 위해 필드 형태를 맞춥니다. export async function GET() { const prisma = new PrismaClient(); try { - const handles = await prisma.userHandle.findMany({ - orderBy: { createtime: 'desc' }, - select: { id: true, email: true, handle: true, isApproved: true, createtime: true, icon: true } + const handles = await prisma.handle.findMany({ + orderBy: { handle: 'asc' }, + 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) { console.error('admin user_handles 오류:', e); return NextResponse.json({ error: '조회 실패' }, { status: 500 }); diff --git a/app/api/channel/list/route.ts b/app/api/channel/list/route.ts index 8b368d4..f4c47f3 100644 --- a/app/api/channel/list/route.ts +++ b/app/api/channel/list/route.ts @@ -5,44 +5,29 @@ import { PrismaClient } from '@/app/generated/prisma'; export async function GET() { const session = await auth(); - if (!session) { + if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - - const email = session.user?.email as string | undefined; - if (!email) { - return NextResponse.json({ error: '세션 이메일을 찾을 수 없습니다' }, { status: 400 }); - } + const email = session.user.email as string; const prisma = new PrismaClient(); try { - // Admin: return all handles + let rows; if (email === 'wsx204@naver.com') { - const all = await prisma.handle.findMany({ + // 관리자: 전체 핸들 + rows = await prisma.handle.findMany({ 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 }); } - - // 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 => ({ + const items = rows.map(h => ({ id: h.id, handle: h.handle, createtime: new Date().toISOString(), diff --git a/app/api/channel/stats/route.ts b/app/api/channel/stats/route.ts new file mode 100644 index 0000000..7c4cdeb --- /dev/null +++ b/app/api/channel/stats/route.ts @@ -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 {} + } +} + + diff --git a/app/api/contents/mycontent/route.ts b/app/api/contents/mycontent/route.ts index fd11d5c..93e62c4 100644 --- a/app/api/contents/mycontent/route.ts +++ b/app/api/contents/mycontent/route.ts @@ -118,7 +118,8 @@ export async function GET(request: Request) { cur.validViews += r.validViews || 0 cur.premiumViews += r.premiumViews || 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) } // 빈 버킷 채우기 (연속 타임라인) @@ -189,7 +190,7 @@ export async function GET(request: Request) { const premiumViews = g._sum?.premiumViews ?? 0 const watchTime = g._sum?.watchTime ?? 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 { id: c?.id ?? g.contentId, subject: c?.subject ?? '', diff --git a/app/components/Dashboard.tsx b/app/components/Dashboard.tsx index 5e59bdf..c7cbb97 100644 --- a/app/components/Dashboard.tsx +++ b/app/components/Dashboard.tsx @@ -389,18 +389,18 @@ export default function Page({user}: {user: any}) { py-2 ">
-
+
{myChannelList.length === 0 ? ( -
+
등록된 채널이 없습니다.
) : visibleChannelIds.size === 0 ? ( -
+
선택된 채널이 없습니다. 오른쪽 필터에서 채널을 선택하세요.
) : ( myChannelList.filter(c => visibleChannelIds.has(c.id)).map((channel, index) => ( -
+
{channel.icon ? ( channel icon @@ -540,8 +540,8 @@ export default function Page({user}: {user: any}) { {formatNumberWithCommas(r.watchTime)} {formatNumberWithCommas(r.views)} {formatNumberWithCommas(r.premiumViews)} - {formatNumberWithCommas(r.validViews)} - + {formatNumberWithCommas(r.validViews)} + {formatNumberWithCommas(Math.round(r.expectedRevenue))} diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx index 5b05451..96b02ba 100644 --- a/app/components/NavBar.tsx +++ b/app/components/NavBar.tsx @@ -63,7 +63,8 @@ const NavBar: React.FC = ({ isOpen, setIsOpen, user }) => { 마이채널관리 -
  • + {false && user?.email === 'wsx204@naver.com' && (
  • )} + {/*
  • setIsOpen(false)}>
    @@ -73,7 +74,7 @@ const NavBar: React.FC = ({ isOpen, setIsOpen, user }) => { 정산관리 -
  • + */}
  • setIsOpen(false)}>
    @@ -87,6 +88,16 @@ const NavBar: React.FC = ({ isOpen, setIsOpen, user }) => {
    + {user?.email === 'wsx204@naver.com' && ( +
    + setIsOpen(false)}> + + + + 관리자 + +
    + )}
    diff --git a/app/globals.css b/app/globals.css index d894de5..b6eb377 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 { position: relative; /* 부모 요소에 상대 위치를 설정 */ padding: 0px 0px 0px 80px !important; diff --git a/app/page.tsx b/app/page.tsx index 6a80070..bbae8cd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -41,7 +41,7 @@ export default function Home() {
    - logo + logo
    콘텐츠 음원 수익
    에버팩토리에서 편리하게
    {/*
    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi lobortis maximus
    */} diff --git a/app/usr/2_mychannel/page.tsx b/app/usr/2_mychannel/page.tsx index 4a923c1..c84514b 100644 --- a/app/usr/2_mychannel/page.tsx +++ b/app/usr/2_mychannel/page.tsx @@ -29,6 +29,7 @@ export default function Page() { is_approved: boolean; icon: string; }[]>([]); + const [stats, setStats] = useState>({}); const [registerCode, setRegisterCode] = useState(""); @@ -86,12 +87,23 @@ export default function Page() { const data = await resp.json(); setChannelList(data.items); 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 = {}; + for (const it of (sdata.items || [])) map[it.handleId] = { videoCount: it.videoCount, views: it.views }; + setStats(map); + } } catch (e) { console.error('list_channel 요청 에러:', e); } } const delete_channel = async (handle: string) => { + alert("삭제 비활성화 관리자에게 문의하세요."); + return; if (isLoading) return; if (!handle) return; const ok = confirm(`정말 "${handle}" 채널 연결을 해제하시겠습니까?`); @@ -139,25 +151,21 @@ export default function Page() {
    - +
    - + - - - - - + @@ -176,13 +184,11 @@ export default function Page() { - - - - - + +
    채널 핸들 등록 날짜승인여부 영상수 조회수예상수익삭제삭제
    {channel.createtime.split("T")[0]}{channel.is_approved? "승인" : "미승인"} - - - + {stats[channel.id]?.videoCount ?? '-'}{stats[channel.id]?.views ?? '-'}