first commit
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/app/generated/prisma
|
||||
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
112
app/admin/noticeboard/write/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
import { Crepe } from "@milkdown/crepe";
|
||||
import { MilkdownProvider } from "@milkdown/react";
|
||||
import { useCallback, useState,useRef } from "react";
|
||||
import { MilkdownEditor } from "@/app/components/editor";
|
||||
|
||||
export default function Page() {
|
||||
const titleRef = useRef<HTMLInputElement>(null!);
|
||||
const tagRef = useRef<HTMLSelectElement>(null!);
|
||||
|
||||
const crepeRef = useRef<Crepe>(null!);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
|
||||
const handlePost = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/notice', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: titleRef.current?.value,
|
||||
content: crepeRef.current?.getMarkdown(),
|
||||
tag: tagRef.current?.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setToastMessage('Post successful!');
|
||||
setTimeout(() => {
|
||||
setToastMessage('');
|
||||
window.location.href = '/usr/4_noticeboard'; // Redirect after success
|
||||
}, 1000); // 3 seconds delay
|
||||
} else {
|
||||
setToastMessage('Failed to post.');
|
||||
}
|
||||
} catch (error) {
|
||||
setToastMessage('An error occurred.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className=" bg-white h-full min-h-[760px]">
|
||||
<div className="h-full grid grid-rows-[20px_74px_minmax(300px,auto)_240px] grid-cols-[minmax(500px,880px)] justify-center py-8 gap-5
|
||||
2xl:grid-cols-[minmax(500px,880px)_auto] 2xl:grid-rows-[20px_74px_minmax(300px,1fr)_auto]
|
||||
">
|
||||
<div className="ml-1 text-xl leading-8 font-extrabold 2xl:col-[1/3] 2xl:row-[1/2]">게시글작성</div>
|
||||
<div className="flex flex-col justify-between 2xl:col-[1/2] 2xl:row-[2/3]">
|
||||
<div className="h-[14px] text-sm leading-4 text-[#3f2929] ml-1">
|
||||
제목
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="h-[48px] border-1 border-[#d5d5d5] rounded-lg px-2 mx-1"
|
||||
placeholder="제목을 입력하세요"
|
||||
ref={titleRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between 2xl:col-[1/2] 2xl:row-[3/4]">
|
||||
<div className="h-[14px] text-sm leading-4 text-[#3f2929] ml-1"> 내용 </div>
|
||||
<div className="h-[13px]"></div>
|
||||
|
||||
<div className="grow-1 border-1 border-[#d5d5d5] rounded-lg px-2 mx-1 overflow-y-auto">
|
||||
<MilkdownProvider>
|
||||
<MilkdownEditor editorRef={crepeRef} />
|
||||
</MilkdownProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center 2xl:col-[2/3] 2xl:row-[2/4] 2xl:justify-start">
|
||||
<div className="flex flex-col ">
|
||||
<div className="h-[14px] text-sm leading-4 text-[#3f2929] ml-1">
|
||||
작업 태그 설정
|
||||
</div>
|
||||
<div className="h-[13px]"></div>
|
||||
<select
|
||||
className="h-[48px] mx-1 border-1 border-[#d5d5d5] rounded-lg px-2"
|
||||
ref={tagRef}
|
||||
>
|
||||
<option value="중요">중요</option>
|
||||
<option value="0">없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="h-[48px]"></div>
|
||||
|
||||
<div className="flex flex-col justify-between h-[116px] items-center">
|
||||
<button onClick={handlePost} disabled={isLoading} className="w-[324px] h-[48px] border-1 border-[#D73B29] rounded-lg flex items-center justify-center text-white bg-[#F94B37] font-extrabold text-xl cursor-pointer hover:bg-[#D73B29]">
|
||||
{isLoading ? 'Posting...' : '게시'}
|
||||
</button>
|
||||
<div
|
||||
className="w-[324px] h-[48px] border-1 border-[#D5D5D5] rounded-lg flex items-center justify-center font-medium text-xl cursor-pointer hover:bg-[#E5E5E5]"
|
||||
onClick={() => window.location.href = '/usr/4_noticeboard'}
|
||||
>
|
||||
취소
|
||||
</div>
|
||||
</div>
|
||||
{toastMessage && (
|
||||
<div className="fixed bottom-4 right-4 bg-gray-800 text-white p-4 rounded shadow-lg">
|
||||
{toastMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
312
app/admin/page.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const router = useRouter();
|
||||
const noticeSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [value, setValue] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [saving, setSaving] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [okMsg, setOkMsg] = useState<string>("");
|
||||
const [handles, setHandles] = useState<Array<{
|
||||
id: string;
|
||||
email: string;
|
||||
handle: string;
|
||||
icon: string;
|
||||
isApproved: boolean;
|
||||
createtime: string;
|
||||
}>>([]);
|
||||
const [loadingHandles, setLoadingHandles] = useState<boolean>(false);
|
||||
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
|
||||
const [notices, setNotices] = useState<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tag: string;
|
||||
pubDate: string;
|
||||
}>>([]);
|
||||
const [loadingNotices, setLoadingNotices] = useState<boolean>(false);
|
||||
const [pendingNoticeIds, setPendingNoticeIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadHandles = async () => {
|
||||
setLoadingHandles(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/user_handles', { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (res.ok) setHandles(data.items ?? []);
|
||||
} finally {
|
||||
setLoadingHandles(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 최초 진입 시 핸들 목록 로드
|
||||
loadHandles();
|
||||
loadNotices();
|
||||
}, []);
|
||||
|
||||
const loadNotices = async () => {
|
||||
setLoadingNotices(true);
|
||||
try {
|
||||
const res = await fetch('/api/notice', { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (res.ok) setNotices(data ?? []);
|
||||
} finally {
|
||||
setLoadingNotices(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotice = async (id: string) => {
|
||||
if (!confirm('이 공지글을 삭제하시겠습니까? 삭제 후 되돌릴 수 없습니다.')) return;
|
||||
setPendingNoticeIds(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await fetch(`/api/notice?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error ?? '삭제 실패');
|
||||
}
|
||||
setNotices(prev => prev.filter(n => n.id !== id));
|
||||
} catch (e) {
|
||||
alert('삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setPendingNoticeIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleApprove = async (id: string, approve: boolean) => {
|
||||
setPendingIds(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await fetch('/api/admin/user_handles/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, approve }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.ok) throw new Error(data?.error ?? '변경 실패');
|
||||
setHandles(prev => prev.map(h => h.id === id ? { ...h, isApproved: approve } : h));
|
||||
} catch (e) {
|
||||
alert('변경 실패');
|
||||
} finally {
|
||||
setPendingIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[900px] mx-auto p-4">
|
||||
<div className="w-full flex justify-between mb-3">
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={() => router.push('/usr/1_dashboard')}
|
||||
>
|
||||
대시보드로
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-4">관리자 설정</h1>
|
||||
<div className="border-1 border-[#e6e9ef] rounded-lg bg-white p-4">
|
||||
<div className="mb-2 font-semibold">Cost Per View</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
className="border-1 border-border-pale rounded-md px-2 py-1 w-[220px]"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<button
|
||||
className="px-4 py-2 rounded-md text-white bg-[#F94B37] disabled:opacity-60"
|
||||
onClick={onSave}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{saving ? '저장 중...' : '적용'}
|
||||
</button>
|
||||
</div>
|
||||
{loading && <div className="text-sm text-[#848484] mt-2">불러오는 중...</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>}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span>User Handle 목록</span>
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={loadHandles}
|
||||
>새로고침</button>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full min-w-[680px] table-fixed border-separate border-spacing-y-1">
|
||||
<colgroup>
|
||||
<col className="w-[48px]" />
|
||||
<col className="w-[200px]" />
|
||||
<col className="w-[220px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[160px]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{handles.map((h) => (
|
||||
<tr key={h.id} className="h-[48px] bg-white border-1 border-[#e6e9ef] rounded-lg">
|
||||
<td className="px-1">
|
||||
<div className="w-[36px] h-[36px] rounded-full border-1 border-[#e6e9ef] overflow-hidden">
|
||||
{h.icon ? (
|
||||
<img src={h.icon} alt="icon" className="w-[36px] h-[36px] rounded-full" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-[#f0f0f0]" />
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${h.isApproved ? '' : 'text-[#F94B37]'}`}>{h.isApproved ? '승인' : '미승인'}</span>
|
||||
<button
|
||||
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)}
|
||||
disabled={pendingIds.has(h.id)}
|
||||
>
|
||||
{h.isApproved ? '미승인으로' : '승인'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 text-sm">{h.createtime?.split('T')[0]}</td>
|
||||
</tr>
|
||||
))}
|
||||
{handles.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-4 text-[#848484]">
|
||||
{loadingHandles ? '불러오는 중...' : '데이터가 없습니다'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={noticeSectionRef} className="border-1 border-[#e6e9ef] rounded-lg bg-white p-4 mt-6">
|
||||
<div className="mb-2 font-semibold flex items-center justify-between">
|
||||
<span>NoticeBoard 목록</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={() => router.push('/usr/4_noticeboard')}
|
||||
>게시글 목록</button>
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={() => router.push('/admin/noticeboard/write')}
|
||||
>글쓰기</button>
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={loadNotices}
|
||||
>새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full min-w-[680px] table-fixed border-separate border-spacing-y-1">
|
||||
<colgroup>
|
||||
<col className="w-[220px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[100px]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notices.map((n) => (
|
||||
<tr key={n.id} className="h-[48px] bg-white border-1 border-[#e6e9ef] rounded-lg">
|
||||
<td className="px-2 font-semibold truncate">{n.title}</td>
|
||||
<td className="px-2 text-sm text-[#555]">{n.tag}</td>
|
||||
<td className="px-2 text-sm truncate">{n.content}</td>
|
||||
<td className="px-2 text-sm">{new Date(n.pubDate).toISOString().split('T')[0]}</td>
|
||||
<td className="px-2 text-sm">
|
||||
<button
|
||||
className="px-2 py-1 text-xs rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] disabled:opacity-60"
|
||||
onClick={() => deleteNotice(n.id)}
|
||||
disabled={pendingNoticeIds.has(n.id)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{notices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-4 text-[#848484]">
|
||||
{loadingNotices ? '불러오는 중...' : '데이터가 없습니다'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
29
app/api/admin/user_handles/approve/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
app/api/admin/user_handles/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@/app/generated/prisma';
|
||||
|
||||
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 }
|
||||
});
|
||||
return NextResponse.json({ items: handles });
|
||||
} catch (e) {
|
||||
console.error('admin user_handles 오류:', e);
|
||||
return NextResponse.json({ error: '조회 실패' }, { status: 500 });
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2
app/api/auth/[...nextauth]/route.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/auth" // Referring to the auth.ts we just created
|
||||
export const { GET, POST } = handlers
|
||||
67
app/api/channel_code/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { PrismaClient, Prisma } from '@/app/generated/prisma';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
console.log(session)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const handle = searchParams.get('handle');
|
||||
|
||||
if (!handle) {
|
||||
return NextResponse.json({ error: '핸들이 필요합니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 안전한 난수 기반 32자 코드 (충돌 확률 극히 낮음)
|
||||
const randomcode = randomBytes(16).toString('hex');
|
||||
|
||||
// 중복 여부 확인 (email + handle 기준)
|
||||
const exists = await prisma.registerChannel.findFirst({
|
||||
where: {
|
||||
email: session.user?.email as string,
|
||||
handle: handle,
|
||||
},
|
||||
select: { id: true, randomcode: true },
|
||||
});
|
||||
if (exists) {
|
||||
return NextResponse.json(
|
||||
{ error: '이미 등록된 요청입니다', randomcode: exists.randomcode },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// DB에 저장
|
||||
const created = await prisma.registerChannel.create({
|
||||
data: {
|
||||
email: session.user?.email as string,
|
||||
handle: handle,
|
||||
randomcode: randomcode
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: '성공', code: handle, randomcode: created.randomcode, id: created.id }, { status: 200 });
|
||||
} catch (error: unknown) {
|
||||
console.error('에러 발생:', error);
|
||||
|
||||
// Prisma 에러 코드별 분기
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
// 고유 제약조건 위반 등
|
||||
if (error.code === 'P2002') {
|
||||
return NextResponse.json({ error: '중복된 값으로 저장할 수 없습니다' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: '요청이 올바르지 않습니다', code: error.code }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientValidationError) {
|
||||
return NextResponse.json({ error: '유효하지 않은 데이터입니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '서버 에러가 발생했습니다' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
14
app/api/channels/notuse.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// import { PrismaClient } from '../../generated/prisma/client';
|
||||
|
||||
// const prisma = new PrismaClient();
|
||||
|
||||
// export async function GET() {
|
||||
// const publishers = await prisma.content.findMany({
|
||||
// distinct: ['publisher'],
|
||||
// select: {
|
||||
// publisher: true,
|
||||
// },
|
||||
// });
|
||||
// const publisherList = publishers.map(p => p.publisher);
|
||||
// return Response.json(publisherList);
|
||||
// }
|
||||
8
app/api/contents/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PrismaClient } from '../../generated/prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
const contents = await prisma.content.findMany();
|
||||
return Response.json(contents);
|
||||
}
|
||||
38
app/api/cost_per_view/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@/app/generated/prisma';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const row = await prisma.costPerView.findUnique({ where: { id: 1 } });
|
||||
return NextResponse.json({ value: row?.costPerView ?? null });
|
||||
} catch (e) {
|
||||
console.error('GET /api/cost_per_view 오류:', e);
|
||||
return NextResponse.json({ error: '조회 실패' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const valueRaw = body?.value;
|
||||
const value = typeof valueRaw === 'string' ? parseFloat(valueRaw) : valueRaw;
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return NextResponse.json({ error: '유효한 숫자 값을 제공하세요' }, { status: 400 });
|
||||
}
|
||||
|
||||
const saved = await prisma.costPerView.upsert({
|
||||
where: { id: 1 },
|
||||
update: { costPerView: value },
|
||||
create: { id: 1, costPerView: value },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, value: saved.costPerView });
|
||||
} catch (e) {
|
||||
console.error('POST /api/cost_per_view 오류:', e);
|
||||
return NextResponse.json({ error: '저장 실패' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
app/api/list_channel/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { PrismaClient } from '@/app/generated/prisma';
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const email = session.user?.email as string | undefined;
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: '세션 이메일을 찾을 수 없습니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
if (email === 'wsx204@naver.com') {
|
||||
const allHandles = await prisma.userHandle.findMany({
|
||||
orderBy: { createtime: 'desc' },
|
||||
select: { id: true, email: true, handle: true, isApproved: true, createtime: true, icon: true }
|
||||
});
|
||||
return NextResponse.json({ items: allHandles });
|
||||
}
|
||||
else{
|
||||
const handles = await prisma.userHandle.findMany({
|
||||
where: { email },
|
||||
orderBy: { createtime: 'desc' },
|
||||
select: { id: true, email: true, handle: true, isApproved: true, createtime: true, icon: true }
|
||||
});
|
||||
return NextResponse.json({ items: handles });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('list_channel 오류:', e);
|
||||
return NextResponse.json({ error: '조회 실패' }, { status: 500 });
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
115
app/api/my_contents/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { PrismaClient } from '@/app/generated/prisma';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function parseDateOr(value: string | null, fallback: Date): Date {
|
||||
if (!value) return fallback;
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime()) ? fallback : d;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const email = session.user?.email as string | undefined;
|
||||
if (!email) return NextResponse.json({ error: '세션 이메일을 찾을 수 없습니다' }, { status: 400 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const endDefault = new Date();
|
||||
const startDefault = new Date(endDefault.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const startParam = searchParams.get('start');
|
||||
const endParam = searchParams.get('end');
|
||||
const startDate = startParam ? new Date(startParam) : startDefault;
|
||||
const endDate = endParam ? new Date(endParam) : endDefault;
|
||||
// 날짜 경계 보정: 하루 전체 포함 (UTC 기준)
|
||||
const startInclusive = new Date(startDate);
|
||||
startInclusive.setUTCHours(0, 0, 0, 0);
|
||||
const endInclusive = new Date(endDate);
|
||||
endInclusive.setUTCHours(23, 59, 59, 999);
|
||||
|
||||
// 안전가드
|
||||
if (endInclusive < startInclusive) endInclusive.setTime(startInclusive.getTime());
|
||||
console.log('startDate', startDate.toISOString());
|
||||
console.log('endDate', endDate.toISOString());
|
||||
|
||||
// 1) 내 핸들
|
||||
const handles = await prisma.userHandle.findMany({
|
||||
where: { email },
|
||||
select: { id: true, handle: true, icon: true }
|
||||
});
|
||||
if (handles.length === 0) return NextResponse.json({ items: [] });
|
||||
const handleStrs = handles.map(h => h.handle);
|
||||
const handleByStr = new Map(handles.map(h => [h.handle, h] as const));
|
||||
|
||||
// 2) 매핑된 콘텐츠
|
||||
const links = await prisma.contentHandle.findMany({
|
||||
where: { handle: { in: handleStrs } },
|
||||
select: { contentId: true, handle: true }
|
||||
});
|
||||
if (links.length === 0) return NextResponse.json({ items: [] });
|
||||
const contentIds = links.map(l => l.contentId);
|
||||
const normalizeId = (s: string) => (s ?? '').trim();
|
||||
const contentIdsNormalized = Array.from(new Set(contentIds.map(normalizeId)));
|
||||
const idsForQuery = Array.from(new Set([...contentIds, ...contentIdsNormalized]));
|
||||
const handleByContentId = new Map(links.map(l => [l.contentId, l.handle] as const));
|
||||
|
||||
// 3) 콘텐츠 본문
|
||||
const contents = await prisma.content.findMany({
|
||||
where: { id: { in: contentIds } },
|
||||
select: {
|
||||
id: true, subject: true, pubDate: true,
|
||||
views: true, premiumViews: true, watchTime: true
|
||||
}
|
||||
});
|
||||
|
||||
// 4) 기간 합계 유효조회수 (findMany로 가져와 JS에서 합산 - groupBy 일부 환경 이슈 회피)
|
||||
const viewRows = await prisma.viewPerDay.findMany({
|
||||
where: {
|
||||
contented: { in: idsForQuery },
|
||||
date: { gte: startInclusive, lte: endInclusive }
|
||||
},
|
||||
select: { contented: true, validViewDay: true }
|
||||
});
|
||||
const validSumById = new Map<string, number>();
|
||||
for (const r of viewRows) {
|
||||
const key = normalizeId(r.contented);
|
||||
validSumById.set(key, (validSumById.get(key) ?? 0) + (r.validViewDay ?? 0));
|
||||
}
|
||||
|
||||
// 5) 비용 단가
|
||||
const cpvRow = await prisma.costPerView.findUnique({ where: { id: 1 } });
|
||||
const cpv = cpvRow?.costPerView ?? 0;
|
||||
|
||||
const items = contents.map(c => {
|
||||
const handle = handleByContentId.get(c.id) ?? '';
|
||||
const handleObj = handleByStr.get(handle);
|
||||
const validViews = validSumById.get(normalizeId(c.id)) ?? 0;
|
||||
const expectedRevenue = validViews * cpv;
|
||||
return {
|
||||
id: c.id,
|
||||
subject: c.subject,
|
||||
pubDate: c.pubDate,
|
||||
views: c.views,
|
||||
premiumViews: c.premiumViews,
|
||||
watchTime: c.watchTime,
|
||||
handle,
|
||||
handleId: handleObj?.id ?? null,
|
||||
icon: handleObj?.icon ?? '',
|
||||
validViews,
|
||||
expectedRevenue,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ items, cpv, start: startInclusive.toISOString(), end: endInclusive.toISOString() });
|
||||
} catch (e) {
|
||||
console.error('my_contents 오류:', e);
|
||||
return NextResponse.json({ error: '조회 실패' }, { status: 500 });
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
app/api/notice/item/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { PrismaClient } from '../../../generated/prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
const notices = await prisma.noticeBoard.findMany({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
pubDate: true,
|
||||
tag: true,
|
||||
// 필요한 다른 필드들도 추가
|
||||
},
|
||||
});
|
||||
return Response.json(notices);
|
||||
}
|
||||
59
app/api/notice/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { PrismaClient } from '../../generated/prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
const notices = await prisma.noticeBoard.findMany({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
pubDate: true,
|
||||
tag: true,
|
||||
content: true,
|
||||
// 필요한 다른 필드들도 추가
|
||||
},
|
||||
});
|
||||
return Response.json(notices);
|
||||
}
|
||||
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { title, content, tag } = await request.json();
|
||||
const newNotice = await prisma.noticeBoard.create({
|
||||
data: {
|
||||
title,
|
||||
content,
|
||||
tag,
|
||||
pubDate: new Date(),
|
||||
},
|
||||
});
|
||||
return Response.json(newNotice, { status: 201 });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const { id, ...updateData } = await request.json();
|
||||
const updatedNotice = await prisma.noticeBoard.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
return Response.json(updatedNotice);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return new Response('ID is required', { status: 400 });
|
||||
}
|
||||
|
||||
const deletedNotice = await prisma.noticeBoard.update({
|
||||
where: { id },
|
||||
data: { isDeleted: true },
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(deletedNotice), { status: 200 });
|
||||
}
|
||||
46
app/api/register_channel/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const handle = searchParams.get('handle');
|
||||
const email = session.user?.email as string | undefined;
|
||||
|
||||
if (!handle) {
|
||||
return NextResponse.json({ error: '핸들이 필요합니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: '세션 이메일을 찾을 수 없습니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
const upstream = await fetch('http://localhost:10001/register_channel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email, handle })
|
||||
});
|
||||
|
||||
let data: any = null;
|
||||
const text = await upstream.text();
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = { message: text };
|
||||
}
|
||||
|
||||
return NextResponse.json(data ?? {}, { status: upstream.status });
|
||||
} catch (error) {
|
||||
console.error('register_channel 프록시 오류:', error);
|
||||
return NextResponse.json({ error: '업스트림 요청 실패' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
app/api/upload/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
|
||||
export const runtime = "nodejs"; // 파일 시스템 사용
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const form = await req.formData();
|
||||
const file = form.get("file") as File | null;
|
||||
if (!file) return NextResponse.json({ ok: false, error: "NO_FILE" }, { status: 400 });
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const ext = (file.name.split(".").pop() || "png").toLowerCase();
|
||||
const name = `${crypto.randomUUID()}.${ext}`;
|
||||
const dir = path.join(process.cwd(), "public", "uploads");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, name), bytes);
|
||||
|
||||
// 정적 서빙: public/uploads/...
|
||||
const url = `/uploads/${name}`;
|
||||
return NextResponse.json({ ok: true, url });
|
||||
}
|
||||
122
app/components/CalenderSelector.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import DayRange from "./svgs/dayRange";
|
||||
import OneMonth from "./svgs/oneMonth";
|
||||
import Realtime from "./svgs/realtime";
|
||||
import Arrow from "./svgs/arrow";
|
||||
import { DateRangeEnum } from "@/app/constants/dateRange";
|
||||
import { useState,useEffect } from "react";
|
||||
|
||||
export default function CalenderSelector( {dateString, is_small, onRangeChange}: {dateString: string, is_ri: boolean, is_small: boolean, onRangeChange?: (start: Date, end: Date) => void} ) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<number>(DateRangeEnum.ONE_MONTH);
|
||||
const [rangeStart, setRangeStart] = useState<Date>(new Date());
|
||||
const [rangeEnd, setRangeEnd] = useState<Date>(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const endDate = new Date(Date.now());
|
||||
setRangeStart(startDate);
|
||||
setRangeEnd(endDate);
|
||||
onRangeChange?.(startDate, endDate);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative inline-block">
|
||||
<div className={`
|
||||
${is_small ? 'w-[267px] h-[28px]':'w-[267px] h-[36px]'}
|
||||
${isOpen ? 'border-[#F94B37] bg-[#FFF3F2] bor' : 'border-border-pale bg-white '}
|
||||
border-1 rounded-lg flex flex-row items-center justify-between gap-2 px-2 cursor-pointer`}
|
||||
onClick={() => {
|
||||
console.log('click');
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{selected === DateRangeEnum.DAY_RANGE && <DayRange color="#848484" width={is_small ? 16 : 20} height={is_small ? 16 : 20} />}
|
||||
{selected === DateRangeEnum.ONE_MONTH&& <OneMonth color="#848484" width={is_small ? 16 : 20} height={is_small ? 16 : 20} />}
|
||||
{selected === DateRangeEnum.ALL&& <Realtime color="#848484" width={is_small ? 16 : 20} height={is_small ? 16 : 20} />}
|
||||
</div>
|
||||
<div className={`flex-1 min-w-0 font-semibold ${is_small ? 'text-xs':'text-sm'}`}>
|
||||
{selected === DateRangeEnum.DAY_RANGE && `${rangeStart.toISOString().split('T')[0]} ~ ${rangeEnd.toISOString().split('T')[0]}`}
|
||||
{selected === DateRangeEnum.ONE_MONTH&& '최근 1개월'}
|
||||
{selected === DateRangeEnum.ALL&& '전체'}
|
||||
</div>
|
||||
<div className={`pt-[3px] transition-transform ${isOpen ? 'rotate-180' : ''} flex-shrink-0`}>
|
||||
<Arrow color="#A4A0A0" width={is_small ? 12 : 18} height={is_small ? 8 : 12} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className={`absolute left-0 top-full mt-1 ${is_small ? 'w-[267px]' : 'w-[267px]'} bg-white border-1 border-border-pale rounded-lg shadow-sm p-1 z-50`}>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
className={`flex items-center gap-2 px-2 h-[32px] rounded-md hover:bg-[#f6f6f6] text-left ${selected === DateRangeEnum.ALL ? 'bg-[#FFF3F2] font-semibold' : ''}`}
|
||||
onClick={() => {
|
||||
setSelected(DateRangeEnum.ALL);
|
||||
const end = new Date();
|
||||
const start = new Date(0);
|
||||
setRangeStart(start);
|
||||
setRangeEnd(end);
|
||||
onRangeChange?.(start, end);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<Realtime color={selected === DateRangeEnum.ALL ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||
<span className="text-sm">전체</span>
|
||||
</button>
|
||||
<div className="h-1 "></div>
|
||||
<button
|
||||
className={`flex items-center gap-2 px-2 h-[32px] rounded-md hover:bg-[#f6f6f6] text-left ${selected === DateRangeEnum.ONE_MONTH? 'bg-[#FFF3F2] font-semibold' : ''}`}
|
||||
onClick={() => {
|
||||
setSelected(DateRangeEnum.ONE_MONTH);
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - 30*24*60*60*1000);
|
||||
setRangeStart(start);
|
||||
setRangeEnd(end);
|
||||
onRangeChange?.(start, end);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<OneMonth color={selected === DateRangeEnum.ONE_MONTH ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||
<span className="text-sm">최근 1개월</span>
|
||||
</button>
|
||||
<div className="h-1 "></div>
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 pr-0 h-[32px] rounded-md hover:bg-[#f6f6f6] text-left ${selected === DateRangeEnum.DAY_RANGE ? 'bg-[#FFF3F2] font-semibold' : ''}`}
|
||||
onClick={() => {
|
||||
// setSelected(DateRangeEnum.DAY_RANGE);
|
||||
// setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<DayRange color={selected === DateRangeEnum.DAY_RANGE ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={rangeStart.toISOString().split('T')[0]}
|
||||
onChange={(e) => setRangeStart(new Date(e.target.value))}
|
||||
className="border-1 border-border-pale rounded-md p-0.5 text-xs w-[84px] text-center"
|
||||
/>
|
||||
<span className="mx-0">~</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rangeEnd.toISOString().split('T')[0]}
|
||||
onChange={(e) => setRangeEnd(new Date(e.target.value))}
|
||||
className="border-1 border-border-pale rounded-md p-0.5 text-xs w-[84px] text-center"
|
||||
/>
|
||||
|
||||
<button className="text-xs text-[#848484] border-1 border-border-pale rounded-md p-0.5 px-1 hover:text-[#F94B37] hover:border-[#F94B37]"
|
||||
onClick={() => {
|
||||
setSelected(DateRangeEnum.DAY_RANGE);
|
||||
onRangeChange?.(rangeStart, rangeEnd);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
96
app/components/ChannelFilter.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import SvgChannelFilter from "@/app/components/svgs/svgChannelFilter";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Channel = { id: string; handle: string; icon?: string };
|
||||
|
||||
export default function ChanalFilter({
|
||||
channel_list,
|
||||
visibleSet,
|
||||
onChangeVisible,
|
||||
}:{
|
||||
channel_list: Channel[];
|
||||
visibleSet: Set<string>;
|
||||
onChangeVisible: (nextVisibleIds: string[]) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const allIds = useMemo(() => channel_list.map(c => c.id), [channel_list]);
|
||||
const allChecked = useMemo(() => allIds.every(id => visibleSet.has(id)) && allIds.length > 0, [allIds, visibleSet]);
|
||||
|
||||
const didInitRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// 최초 1회만: 비어있으면 전체 선택으로 초기화
|
||||
if (!didInitRef.current) {
|
||||
didInitRef.current = true;
|
||||
if (visibleSet.size === 0 && allIds.length > 0) {
|
||||
onChangeVisible(allIds);
|
||||
}
|
||||
}
|
||||
}, [allIds]);
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
const next = new Set(visibleSet);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
onChangeVisible(Array.from(next));
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allChecked) {
|
||||
onChangeVisible([]);
|
||||
} else {
|
||||
onChangeVisible(allIds);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className={`w-[36px] h-[36px] border-1 rounded-lg flex items-center justify-center cursor-pointer
|
||||
${isOpen ? 'border-[#F94B37] bg-[#FFF3F2]' : 'border-border-pale bg-white '} `}
|
||||
onClick={()=>{setIsOpen(!isOpen)}}
|
||||
>
|
||||
<SvgChannelFilter color={isOpen ? '#F94B37' : '#848484'} width={20} height={20} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-[-264px] z-50">
|
||||
<div className="w-[300px] min-h-[200px] max-h-[350px] mt-2 border-1 border-border-pale rounded-lg items-center justify-start bg-white shadow-lg flex flex-col p-3 overflow-auto">
|
||||
<div className="w-full flex items-center justify-between border-b-1 text-base font-semibold border-border-pale py-2 px-1">
|
||||
<span>채널 필터</span>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input className="accent-[#F94B37]" type="checkbox" checked={allChecked} onChange={toggleAll} /> 전체 선택
|
||||
</label>
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
{channel_list.map((ch) => (
|
||||
<label
|
||||
key={ch.id}
|
||||
className={`w-full flex items-center justify-start gap-2 py-2 px-2 text-sm cursor-pointer rounded-md ${visibleSet.has(ch.id) ? 'bg-[#FFF3F2]' : ''}`}
|
||||
>
|
||||
<input
|
||||
className="accent-[#F94B37]"
|
||||
type="checkbox"
|
||||
checked={visibleSet.has(ch.id)}
|
||||
onChange={() => toggleOne(ch.id)}
|
||||
/>
|
||||
<div className="w-[20px] h-[20px] rounded-full border-1 border-border-pale overflow-hidden">
|
||||
{ch.icon ? (
|
||||
<img src={ch.icon} className="w-[20px] h-[20px] rounded-full" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-[#e6e9ef]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate">{ch.handle}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 w-full h-[36px] rounded-md bg-[#F94B37] border-1 border-[#D73B29] text-white font-semibold hover:bg-[#D73B29]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
552
app/components/Dashboard.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { DateRangeEnum } from "@/app/constants/dateRange";
|
||||
import { useSession, signIn } from "next-auth/react";
|
||||
|
||||
import CalenderSelector from "@/app/components/CalenderSelector";
|
||||
import SvgChannelFilter from "@/app/components/svgs/svgChannelFilter";
|
||||
import ChannelFilter from "@/app/components/ChannelFilter";
|
||||
|
||||
// App.tsx
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js";
|
||||
import { Line } from "react-chartjs-2";
|
||||
|
||||
// Chart.js 모듈 등록 (꼭 필요)
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
type ListChannelRawItem = {
|
||||
id: string;
|
||||
email?: string;
|
||||
handle: string;
|
||||
isApproved: boolean;
|
||||
createtime: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
type ListChannelItem = {
|
||||
id: string;
|
||||
email?: string;
|
||||
handle: string;
|
||||
createtime: string;
|
||||
is_approved: boolean;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export default function Page({user}: {user: any}) {
|
||||
const [myChannelList, setMyChannelList] = useState<ListChannelItem[]>([]);
|
||||
const [visibleChannelIds, setVisibleChannelIds] = useState<Set<string>>(new Set());
|
||||
const [channelsReady, setChannelsReady] = useState<boolean>(false);
|
||||
const fetchListChannel = useCallback(async (options?: {
|
||||
cache?: RequestCache;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ListChannelItem[]> => {
|
||||
const response = await fetch('/api/list_channel', {
|
||||
cache: options?.cache ?? 'no-store',
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = '';
|
||||
try {
|
||||
message = await response.text();
|
||||
} catch {}
|
||||
throw new Error(`list_channel 요청 실패: ${response.status} ${message}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { items: ListChannelRawItem[] } | unknown;
|
||||
const items = (data as { items: ListChannelRawItem[] })?.items ?? [];
|
||||
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
email: item.email,
|
||||
handle: item.handle,
|
||||
createtime: item.createtime,
|
||||
is_approved: item.isApproved,
|
||||
icon: item.icon,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const [chanellist, setChanellist] = useState<string[]>([]);
|
||||
const [contentlist, setContentlist] = useState<{
|
||||
id: string;
|
||||
subject: string;
|
||||
pubDate: Date;
|
||||
views: number;
|
||||
validViews: number;
|
||||
premiumViews: number;
|
||||
watchTime: number;
|
||||
publisher?: string;
|
||||
}[]>([]);
|
||||
const [selectedChannels, setSelectedChannels] = useState<string[]>(chanellist);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}>({
|
||||
startDate: new Date(),
|
||||
endDate: new Date()
|
||||
});
|
||||
|
||||
const [dateString, setDateString] = useState<string>("2025.03.01 ~ 2025.03.31");
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const [hasRun, setHasRun] = useState(false);
|
||||
|
||||
// 전역 상수는 공용 파일에서 import
|
||||
|
||||
const [globalDateRangeType, setGlobalDateRangeType] = useState<number>(DateRangeEnum.ALL);
|
||||
const [dateRangeBlock1, setDateRangeBlock1] = useState<number>(DateRangeEnum.ALL);
|
||||
const [dateRangeBlock2, setDateRangeBlock2] = useState<number>(DateRangeEnum.ALL);
|
||||
const [dateRangeBlock3, setDateRangeBlock3] = useState<number>(DateRangeEnum.ALL);
|
||||
const [dateRangeBlock4, setDateRangeBlock4] = useState<number>(DateRangeEnum.ALL);
|
||||
const [dateRangeGraph, setDateRangeGraph] = useState<number>(DateRangeEnum.ALL);
|
||||
|
||||
const [startDate, setStartDate] = useState<Date>(new Date(Date.now()-30*24*60*60*1000));
|
||||
const [endDate, setEndDate] = useState<Date>(new Date());
|
||||
const [cpv, setCpv] = useState<number>(0);
|
||||
const [rows, setRows] = useState<Array<{
|
||||
id: string;
|
||||
subject: string;
|
||||
pubDate: string;
|
||||
views: number;
|
||||
premiumViews: number;
|
||||
watchTime: number;
|
||||
handle: string;
|
||||
handleId: string | null;
|
||||
icon: string;
|
||||
validViews: number;
|
||||
expectedRevenue: number;
|
||||
}>>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||
const [sortKey, setSortKey] = useState<'handle'|'subject'|'pubDate'|'watchTime'|'views'|'premiumViews'|'validViews'|'expectedRevenue'>('pubDate');
|
||||
const [sortDir, setSortDir] = useState<'asc'|'desc'>('desc');
|
||||
const handleSort = useCallback((key: 'handle'|'subject'|'pubDate'|'watchTime'|'views'|'premiumViews'|'validViews'|'expectedRevenue') => {
|
||||
setSortDir(prev => (sortKey === key ? (prev === 'asc' ? 'desc' : 'asc') : 'desc'));
|
||||
setSortKey(key);
|
||||
}, [sortKey]);
|
||||
|
||||
const fetchMyContents = useCallback(async (s: Date, e: Date) => {
|
||||
const qs = `start=${encodeURIComponent(s.toISOString())}&end=${encodeURIComponent(e.toISOString())}`;
|
||||
const res = await fetch(`/api/my_contents?${qs}`, { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? 'LOAD_FAILED');
|
||||
const items = data.items ?? [];
|
||||
setCpv(data.cpv ?? 0);
|
||||
setRows(items);
|
||||
// 초기 로드 시 전체 선택
|
||||
setCheckedIds(new Set(items.map((it: any) => it.id)));
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const data = {
|
||||
labels: ["Jan", "Feb", "Mar", "Apr", "May"],
|
||||
datasets: [
|
||||
{
|
||||
label: "",
|
||||
data: [120000, 150000, 200000, 190000, 230000],
|
||||
borderColor: "#F94B37", // 새로운 색상 적용
|
||||
backgroundColor: "rgba(249, 75, 55, 0.5)", // 배경색도 변경
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false, text: "정산 수익 추이" },
|
||||
},
|
||||
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
display: true,
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 20000,
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.4, // 곡선으로 변경
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRun) {
|
||||
if (chartRef.current) {
|
||||
setHasRun(true);
|
||||
chartRef.current.style.width = '10px';
|
||||
setTimeout(() => {
|
||||
chartRef.current!.style.width = '100%';
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [hasRun, chartRef.current]);
|
||||
|
||||
const handleCheckboxChange = (channel: string) => {
|
||||
setSelectedChannels(prev =>
|
||||
prev.includes(channel)
|
||||
? prev.filter(c => c !== channel)
|
||||
: [...prev, channel]
|
||||
);
|
||||
};
|
||||
|
||||
const filteredContentList = contentlist.filter(content =>
|
||||
selectedChannels.length === 0 || (content.publisher && selectedChannels.includes(content.publisher))
|
||||
);
|
||||
|
||||
const formatNumberWithCommas = (number: number): string => {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
};
|
||||
|
||||
|
||||
// useEffect(() => {
|
||||
// setLoading(true);
|
||||
// fetch('/api/channels')
|
||||
// .then(response => response.json())
|
||||
// .then(data => {
|
||||
// setChanellist(data);
|
||||
// setSelectedChannels(data); // 기본값으로 모든 채널 선택
|
||||
// })
|
||||
// .finally(() => setLoading(false));
|
||||
// }, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// setLoading(true);
|
||||
// fetch('/api/contents')
|
||||
// .then(response => response.json())
|
||||
// .then(data => {
|
||||
// const sortedData = data.sort((a: any, b: any) => b.views - a.views);
|
||||
// setContentlist(sortedData);
|
||||
// })
|
||||
// .finally(() => setLoading(false));
|
||||
// }, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
window.resizeTo(500, 500);
|
||||
setTimeout(() => {
|
||||
window.resizeTo(window.screen.availWidth, window.screen.availHeight);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
handleResize();
|
||||
|
||||
}, []);
|
||||
|
||||
// 내 채널 목록: 최초 1회만 호출
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const items = await fetchListChannel({ cache: 'no-store' });
|
||||
console.log('list_channel:', items);
|
||||
setMyChannelList(items);
|
||||
// 초기에는 전체 채널 가시
|
||||
setVisibleChannelIds(new Set(items.map(i => i.id)));
|
||||
setChannelsReady(true);
|
||||
} catch (e) {
|
||||
console.error('list_channel 요청 에러:', e);
|
||||
}
|
||||
})();
|
||||
}, [fetchListChannel]);
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <div className="spinner">Loading...</div>;
|
||||
}
|
||||
|
||||
// 빈 집합은 "아무 채널도 선택되지 않음"으로 간주하여 행을 숨깁니다
|
||||
const visibleRows = rows.filter(r => visibleChannelIds.size > 0 && r.handleId && visibleChannelIds.has(r.handleId));
|
||||
const sortedVisibleRows = useMemo(() => {
|
||||
const copy = [...visibleRows];
|
||||
const factor = sortDir === 'asc' ? 1 : -1;
|
||||
copy.sort((a,b) => {
|
||||
let va: any, vb: any;
|
||||
switch (sortKey) {
|
||||
case 'pubDate': va = new Date(a.pubDate).getTime(); vb = new Date(b.pubDate).getTime(); break;
|
||||
case 'watchTime': va = a.watchTime; vb = b.watchTime; break;
|
||||
case 'views': va = a.views; vb = b.views; break;
|
||||
case 'premiumViews': va = a.premiumViews; vb = b.premiumViews; break;
|
||||
case 'validViews': va = a.validViews; vb = b.validViews; break;
|
||||
case 'expectedRevenue': va = a.expectedRevenue; vb = b.expectedRevenue; break;
|
||||
case 'handle': va = a.handle; vb = b.handle; break;
|
||||
case 'subject': va = a.subject; vb = b.subject; break;
|
||||
default: va = 0; vb = 0;
|
||||
}
|
||||
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * factor;
|
||||
return ((va as number) - (vb as number)) * factor;
|
||||
});
|
||||
return copy;
|
||||
}, [visibleRows, sortKey, sortDir]);
|
||||
const allVisibleChecked = visibleRows.length > 0 && visibleRows.every(r => checkedIds.has(r.id));
|
||||
const totals = useMemo(() => {
|
||||
let views = 0, premium = 0, valid = 0, revenue = 0;
|
||||
for (const r of sortedVisibleRows) {
|
||||
if (checkedIds.has(r.id)) {
|
||||
views += r.views;
|
||||
premium += r.premiumViews;
|
||||
valid += r.validViews;
|
||||
revenue += r.expectedRevenue;
|
||||
}
|
||||
}
|
||||
return { views, premium, valid, revenue: Math.round(revenue) };
|
||||
}, [sortedVisibleRows, checkedIds]);
|
||||
|
||||
// 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제
|
||||
useEffect(() => {
|
||||
const visibleIdSet = new Set(visibleRows.map(r => r.id));
|
||||
setCheckedIds(prev => {
|
||||
const next = new Set(Array.from(prev).filter(id => visibleIdSet.has(id)));
|
||||
return next;
|
||||
});
|
||||
}, [visibleChannelIds, rows]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="
|
||||
grid
|
||||
grid-cols-[1fr_1fr_1fr_1fr]
|
||||
grid-rows-[36px_minmax(300px,auto)_135px_135px_135px_135px]
|
||||
sm:grid-rows-[36px_minmax(400px,600px)_minmax(200px,250px)_minmax(200px,250px)]
|
||||
gap-2
|
||||
w-full
|
||||
h-full
|
||||
lg:gap-5
|
||||
py-2
|
||||
">
|
||||
<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">
|
||||
{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>
|
||||
) : 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>
|
||||
) : (
|
||||
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 className="w-[24px] h-[24px] rounded-full border-1 border-[#e6e9ef] overflow-hidden mr-2">
|
||||
{channel.icon ? (
|
||||
<img src={channel.icon} alt="channel icon" className="w-[24px] h-[24px] rounded-full" />
|
||||
) : (
|
||||
<div className="w-[24px] h-[24px] bg-[#e6e9ef] rounded-full border-1 border-[#848484]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{channel.handle}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className='flex flex-row items-center'>
|
||||
<CalenderSelector
|
||||
dateString={dateString}
|
||||
is_ri={true}
|
||||
is_small={false}
|
||||
onRangeChange={(s,e)=>{
|
||||
setStartDate(s); setEndDate(e);
|
||||
setDateString(`${s.toISOString().slice(0,10)} ~ ${e.toISOString().slice(0,10)}`);
|
||||
fetchMyContents(s,e).catch(console.error);
|
||||
}}
|
||||
/>
|
||||
<div className="w-2"> </div>
|
||||
<ChannelFilter
|
||||
channel_list={myChannelList}
|
||||
visibleSet={visibleChannelIds}
|
||||
onChangeVisible={(next) => setVisibleChannelIds(new Set(next))}
|
||||
/>
|
||||
<div className="w-2"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="
|
||||
border-1 border-[#e6e9ef] rounded-lg bg-white col-[1/5] row-[2/3]
|
||||
min-h-[300px]
|
||||
overflow-auto
|
||||
">
|
||||
<table className="w-full h-full border-separate border-spacing-y-1 table-auto px-[10px]">
|
||||
<colgroup>
|
||||
<col className="w-[56px]" />
|
||||
<col className="min-w-[160px] max-w-[180px]" />
|
||||
<col className="min-w-[140px] w-[160px]" />
|
||||
<col className="min-w-[280px]" />
|
||||
<col className="min-w-[120px] w-[120px]" />
|
||||
<col className="min-w-[120px] w-[120px]" />
|
||||
<col className="min-w-[160px] w-[160px]" />
|
||||
<col className="min-w-[140px] w-[140px]" />
|
||||
<col className="w-[300px]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="sticky top-0 bg-white h-[49px] z-1">
|
||||
<th className="border-b-1 border-[#e6e9ef] pl-2 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-[18px] h-[18px]"
|
||||
checked={allVisibleChecked}
|
||||
onChange={() => {
|
||||
const next = new Set(checkedIds);
|
||||
if (allVisibleChecked) {
|
||||
visibleRows.forEach(r => next.delete(r.id));
|
||||
} else {
|
||||
visibleRows.forEach(r => next.add(r.id));
|
||||
}
|
||||
setCheckedIds(next);
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="border-b-1 right-border border-[#e6e9ef] whitespace-nowrap cursor-pointer select-none" onClick={()=> handleSort('handle')}>핸들
|
||||
<span className={`ml-1 inline-block w-3 text-[10px] ${sortKey==='handle' ? 'opacity-100' : 'opacity-0'}`}>{sortDir==='asc'?'▲':'▼'}</span>
|
||||
</th>
|
||||
<th className="border-b-1 right-border border-[#e6e9ef] whitespace-nowrap cursor-pointer select-none" onClick={()=> handleSort('pubDate')}>게시일
|
||||
<span className={`ml-1 inline-block w-3 text-[10px] ${sortKey==='pubDate' ? 'opacity-100' : 'opacity-0'}`}>{sortDir==='asc'?'▲':'▼'}</span>
|
||||
</th>
|
||||
<th className="border-b-1 right-border border-[#e6e9ef] whitespace-nowrap cursor-pointer select-none" onClick={()=> handleSort('subject')}>영상 제목
|
||||
<span className={`ml-1 inline-block w-3 text-[10px] ${sortKey==='subject' ? 'opacity-100' : 'opacity-0'}`}>{sortDir==='asc'?'▲':'▼'}</span>
|
||||
</th>
|
||||
<th className="border-b-1 right-border border-[#e6e9ef] whitespace-nowrap cursor-pointer select-none" onClick={()=> handleSort('watchTime')}>시청시간
|
||||
<span className={`ml-1 inline-block w-3 text-[10px] ${sortKey==='watchTime' ? 'opacity-100' : 'opacity-0'}`}>{sortDir==='asc'?'▲':'▼'}</span>
|
||||
</th>
|
||||
<th className="border-b-1 right-border border-[#e6e9ef] whitespace-nowrap cursor-pointer select-none" onClick={()=> handleSort('views')}>조회수
|
||||
<span className={`ml-1 inline-block w-3 text-[10px] ${sortKey==='views' ? 'opacity-100' : 'opacity-0'}`}>{sortDir==='asc'?'▲':'▼'}</span>
|
||||
</th>
|
||||
<th className="border-b-1 right-border border-[#e6e9ef] whitespace-nowrap cursor-pointer select-none" onClick={()=> handleSort('premiumViews')}>premium 조회수
|
||||
<span className={`ml-1 inline-block w-3 text-[10px] ${sortKey==='premiumViews' ? 'opacity-100' : 'opacity-0'}`}>{sortDir==='asc'?'▲':'▼'}</span>
|
||||
</th>
|
||||
<th className="border-b-1 right-border border-[#e6e9ef] whitespace-nowrap cursor-pointer select-none bg-[#FFF3F2]" onClick={()=> handleSort('validViews')}>유효 조회수
|
||||
<span className={`ml-1 inline-block w-3 text-[10px] ${sortKey==='validViews' ? 'opacity-100' : 'opacity-0'}`}>{sortDir==='asc'?'▲':'▼'}</span>
|
||||
</th>
|
||||
<th className="border-b-1 border-[#e6e9ef] whitespace-nowrap min-w-[180px] cursor-pointer select-none bg-[#FFF3F2]" onClick={()=> handleSort('expectedRevenue')}>예상수익
|
||||
<span className={`ml-1 inline-block w-3 text-[10px] ${sortKey==='expectedRevenue' ? 'opacity-100' : 'opacity-0'}`}>{sortDir==='asc'?'▲':'▼'}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleChannelIds.size === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center py-6 text-[#848484]">선택된 채널이 없습니다. 채널 필터에서 채널을 선택하세요.</td>
|
||||
</tr>
|
||||
)}
|
||||
{visibleChannelIds.size > 0 && visibleRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center py-6 text-[#848484]">표시할 데이터가 없습니다.</td>
|
||||
</tr>
|
||||
)}
|
||||
{sortedVisibleRows.map((r) => (
|
||||
<tr key={r.id} className="h-[54px] border-1 border-[#e6e9ef] rounded-lg font-semibold">
|
||||
<td className="justify-center items-center rounded-l-lg border-l-1 border-t-1 border-b-1 border-[#e6e9ef] pl-2">
|
||||
<input type="checkbox" className="w-[18px] h-[18px]"
|
||||
checked={checkedIds.has(r.id)}
|
||||
onChange={(e)=>{
|
||||
const next = new Set(checkedIds);
|
||||
if (e.target.checked) next.add(r.id); else next.delete(r.id);
|
||||
setCheckedIds(next);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center whitespace-nowrap overflow-hidden">
|
||||
<div className="flex items-center justify-center">
|
||||
{r.icon && <img src={r.icon} alt="icon" className="w-6 h-6 mr-2" />}
|
||||
{r.handle}
|
||||
</div>
|
||||
</td>
|
||||
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center whitespace-nowrap overflow-hidden">{new Date(r.pubDate).toISOString().slice(0,10)}</td>
|
||||
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center whitespace-nowrap overflow-hidden">{r.subject}</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.premiumViews)}</td>
|
||||
<td className="right-border border-b-1 border-t-1 border-[#e6e9ef] text-center bg-[#FFF3F2]">{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]">
|
||||
<span className="inline-block min-w-[180px] text-center">{formatNumberWithCommas(Math.round(r.expectedRevenue))}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="border-1 border-[#e6e9ef] col-[1/5] sm:col-[1/3] sm:row-[3/4] xl:col-[1/2] xl:row-[3/4] bg-white rounded-lg flex flex-col justify-around items-start p-3">
|
||||
<div>
|
||||
<div className="text-xl font-bold mb-2">총 조회수</div>
|
||||
{/* <div className=""> <CalenderSelector dateString={dateString} is_ri={false} is_small={true} /> </div> */}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold"> {formatNumberWithCommas(totals.views)} <span className="text-sm text-[#848484]"> 회</span></div>
|
||||
{/* <div className="text-xs font-normal text-[#F94B37]"> + 0.24%</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-1 border-[#e6e9ef] col-[1/5] sm:col-[3/5] sm:row-[3/4] xl:col-[2/3] xl:row-[3/4] bg-white rounded-lg flex flex-col justify-around items-start p-3">
|
||||
|
||||
<div>
|
||||
<div className="text-xl font-bold mb-2"> 프리미엄 조회수</div>
|
||||
{/* <div className=""> <CalenderSelector dateString={dateString} is_ri={false} is_small={true} /> </div> */}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold"> {formatNumberWithCommas(totals.premium)} <span className="text-sm text-[#848484]"> 회</span></div>
|
||||
{/* <div className="text-xs font-normal text-[#F94B37]"> + 0.24%</div> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-1 border-[#F94B37] col-[1/5] sm:col-[1/3] sm:row-[4/5] xl:col-[1/2] xl:row-[4/5] bg-white rounded-lg flex flex-col justify-around items-start p-3">
|
||||
<div>
|
||||
<div className="text-xl font-bold mb-2"> 유효 조회수</div>
|
||||
{/* <div className=""> <CalenderSelector dateString={dateString} is_ri={false} is_small={true} /> </div> */}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-3xl font-bold"> {formatNumberWithCommas(totals.valid)} <span className="text-sm text-[#848484]"> 회</span></div>
|
||||
{/* <div className="text-xs font-normal text-[#F94B37]"> + 0.24%</div> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-1 border-[#e6e9ef] col-[1/5] sm:col-[3/5] sm:row-[4/5] xl:col-[2/3] xl:row-[4/5] bg-white rounded-lg flex flex-col justify-around items-start p-3">
|
||||
|
||||
<div>
|
||||
<div className="text-xl font-bold mb-2"> 예상 수익 </div>
|
||||
{/* <div className=""> <CalenderSelector dateString={dateString} is_ri={false} is_small={true} /> </div> */}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold"> {formatNumberWithCommas(totals.revenue)} <span className="text-sm text-[#848484]"> 원</span></div>
|
||||
{/* <div className="text-xs font-normal text-[#0000FF]"> - 1.24%</div> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-1 border-[#F94B37] hidden xl:col-[3/5] xl:row-[3/5] bg-white rounded-lg p-4 xl:flex xl:flex-col">
|
||||
<div className="text-xl font-bold"> 유효 조회수 추이 </div>
|
||||
<div className="text-normal text-gray-500"> 상위 테이블에서 설정한 데이터를 토대로 그래프가 표출됩니다.</div>
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<div className="font-bold text-3xl my-4"> 33,500 </div>
|
||||
{/* <div className=" p-2 pt-5 text-sm font-normal text-[#F94B37]"> + 0.24%</div> */}
|
||||
</div>
|
||||
<div className="w-full flex-1 items-center justify-center flex" ref={chartRef}>
|
||||
<Line data={data} options={options} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
app/components/LayoutClient.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
import { FaBars } from 'react-icons/fa';
|
||||
import { useState } from 'react';
|
||||
import NavBar from '@/app/components/NavBar';
|
||||
|
||||
export default function Layout({ children , session}: { children: React.ReactNode , session: any }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
console.log(session);
|
||||
return (
|
||||
<div className="bg-[#F5F5F5] h-full flex flex-row " onClick={() => setIsOpen(false)}>
|
||||
<NavBar isOpen={isOpen} setIsOpen={setIsOpen} user={session?.user} />
|
||||
<div className="flex-1 h-full lg:mr-5 ">
|
||||
<div className="absolute h-[50px] top-0 left-0 lg:hidden flex flex-row justify-center items-center bg-white border-b-1 border-[#D5D5D5]">
|
||||
<FaBars className="m-2 h-[35px] absolute top-0 left-0 lg:hidden cursor-pointer" onClick={(e) => {e.stopPropagation(); setIsOpen(true)}}/>
|
||||
</div>
|
||||
<div className="absolute h-[50px] top-0 left-[50%] translate-x-[-50%] flex flex-row justify-center items-center cursor-pointer lg:hidden ">
|
||||
<svg width="32" height="20" viewBox="0 0 32 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 13.7815C0 11.1485 1.5051 9.48685 3.07055 8.61376C4.53165 7.7989 6.17151 7.56314 7.25262 7.56307H29.6288C30.9383 7.56309 32 8.65413 32 10C31.9999 11.3458 30.9383 12.4369 29.6288 12.4369H7.25262C6.70651 12.437 5.90554 12.5782 5.33295 12.8974C5.06963 13.0443 4.93117 13.1859 4.86171 13.2901C4.80809 13.3706 4.74246 13.5037 4.74246 13.7815C4.7425 14.0589 4.80813 14.1913 4.86171 14.2718C4.93111 14.376 5.06944 14.5175 5.33295 14.6644C5.90555 14.9838 6.70643 15.126 7.25262 15.1261H23.5259C24.8355 15.1261 25.8971 16.2172 25.8971 17.5631C25.8969 18.9088 24.8354 20 23.5259 20H7.25262C6.17151 19.9999 4.53165 19.763 3.07055 18.9481C1.50525 18.075 9.38884e-05 16.4143 0 13.7815ZM29.6288 0C30.9383 2.26081e-05 32 1.09107 32 2.43693C32 3.7828 30.9383 4.87385 29.6288 4.87387H14.5759C13.2664 4.87375 12.2046 3.78275 12.2046 2.43693C12.2046 1.09112 13.2664 0.000115545 14.5759 0H29.6288Z" fill="#F94B37" />
|
||||
</svg>
|
||||
<span className="text-black text-2xl font-bold">EVERFACTORY</span>
|
||||
</div>
|
||||
<div className="h-[50px] lg:hidden bg-white">
|
||||
</div>
|
||||
<div className="overflow-y-auto h-[calc(100vh-56px)] lg:h-[calc(100vh)] max-w-[1920px] mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
app/components/MilkdownViewer.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { Crepe as CrepeClass } from '@milkdown/crepe';
|
||||
import { Milkdown, useEditor } from '@milkdown/react';
|
||||
import { Editor, rootCtx, defaultValueCtx, editorViewOptionsCtx } from '@milkdown/core';
|
||||
import { replaceAll } from '@milkdown/utils';
|
||||
// (선택) UI 스타일
|
||||
import '@milkdown/crepe/theme/common/style.css';
|
||||
import '@milkdown/crepe/theme/frame.css';
|
||||
|
||||
type Props = {
|
||||
/** 부모에서 내려주는 마크다운 */
|
||||
value: string;
|
||||
/** 필요 시 외부에서 Crepe 인스턴스 접근 */
|
||||
editorRef?: React.RefObject<CrepeClass>;
|
||||
};
|
||||
|
||||
export const MilkdownViewer: FC<Props> = ({ value, editorRef }) => {
|
||||
const { get } = useEditor((root) => {
|
||||
// 1) Crepe 생성 (초기 값은 parent value)
|
||||
const crepe = new CrepeClass({
|
||||
root,
|
||||
defaultValue: value,
|
||||
featureConfigs: {
|
||||
// 필요 시 다른 기능 설정은 유지 가능
|
||||
},
|
||||
});
|
||||
|
||||
if (editorRef) editorRef.current = crepe;
|
||||
|
||||
// 2) 항상 읽기 전용
|
||||
crepe.editor.config((ctx) => {
|
||||
// 최초 값 보장
|
||||
ctx.set(defaultValueCtx, value);
|
||||
// 편집 불가
|
||||
ctx.update(editorViewOptionsCtx, (prev) => ({
|
||||
...prev,
|
||||
editable: () => false,
|
||||
}));
|
||||
});
|
||||
|
||||
// ✅ 읽기 전용이라 업로드 플러그인은 제거 (원하면 .use(upload) 복구)
|
||||
return crepe.editor;
|
||||
}, []); // 팩토리 고정: 리마운트/루프 방지
|
||||
|
||||
// 3) 부모 value 변경 시 문서 전체 교체
|
||||
useEffect(() => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
editor.action(replaceAll(value));
|
||||
}, [get, value]);
|
||||
|
||||
return (
|
||||
<div className="milkviewer">
|
||||
<Milkdown />
|
||||
</div>
|
||||
)
|
||||
};
|
||||
21
app/components/Modal.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const Modal = ({ isOpen, onClose, children }: { isOpen: boolean, onClose: () => void, children: React.ReactNode }) => {
|
||||
if (!isOpen) return null;
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
console.log(e);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
<div className="fixed inset-0 flex justify-center items-center"> <div className="absolute inset-0 bg-black opacity-50 " onClick={onClose}></div>
|
||||
<div className="bg-white p-8 rounded-xl shadow-lg w-[500px] h-[380px] relative" onClick={(event) => { event.stopPropagation(); }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
21
app/components/ModelNotice.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const Modal = ({ isOpen, onClose, children }: { isOpen: boolean, onClose: () => void, children: React.ReactNode }) => {
|
||||
if (!isOpen) return null;
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
console.log(e);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
<div className="fixed inset-0 flex justify-center items-center"> <div className="absolute inset-0 bg-black opacity-50 " onClick={onClose}></div>
|
||||
<div className="bg-white p-8 rounded shadow-lg w-[90%] h-[90%] min-w-[450px] min-h-[500px] max-w-[800px] max-h-[1000px] overflow-y-auto relative" onClick={(event) => { event.stopPropagation(); }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
125
app/components/NavBar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
interface NavBarProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
user: any;
|
||||
}
|
||||
|
||||
const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
|
||||
return (
|
||||
<nav onClick={(e) => e.stopPropagation()} className={`
|
||||
transition-all duration-300 max-lg:transition-none
|
||||
${isOpen ? 'left-[0px]' : 'left-[-300px]'}
|
||||
absolute
|
||||
z-10
|
||||
bg-white
|
||||
text-black
|
||||
p-4
|
||||
h-[100vh]
|
||||
overflow-y-auto
|
||||
flex-col
|
||||
flex
|
||||
justify-between
|
||||
opacity-95
|
||||
|
||||
lg:z-0
|
||||
lg:left-0
|
||||
lg:relative
|
||||
lg:flex
|
||||
lg:flex-col
|
||||
lg:min-w-[300px]
|
||||
lg:basis-[300px]
|
||||
lg:shirink-0
|
||||
lg:ml-5 sm:mr-5`}>
|
||||
<div className="flex-0 hidden lg:block">
|
||||
<Link href="/">
|
||||
<div className="flex items-center justify-center gap-1 mb-5 p-2 cursor-pointer">
|
||||
<svg width="32" height="20" viewBox="0 0 32 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 13.7815C0 11.1485 1.5051 9.48685 3.07055 8.61376C4.53165 7.7989 6.17151 7.56314 7.25262 7.56307H29.6288C30.9383 7.56309 32 8.65413 32 10C31.9999 11.3458 30.9383 12.4369 29.6288 12.4369H7.25262C6.70651 12.437 5.90554 12.5782 5.33295 12.8974C5.06963 13.0443 4.93117 13.1859 4.86171 13.2901C4.80809 13.3706 4.74246 13.5037 4.74246 13.7815C4.7425 14.0589 4.80813 14.1913 4.86171 14.2718C4.93111 14.376 5.06944 14.5175 5.33295 14.6644C5.90555 14.9838 6.70643 15.126 7.25262 15.1261H23.5259C24.8355 15.1261 25.8971 16.2172 25.8971 17.5631C25.8969 18.9088 24.8354 20 23.5259 20H7.25262C6.17151 19.9999 4.53165 19.763 3.07055 18.9481C1.50525 18.075 9.38884e-05 16.4143 0 13.7815ZM29.6288 0C30.9383 2.26081e-05 32 1.09107 32 2.43693C32 3.7828 30.9383 4.87385 29.6288 4.87387H14.5759C13.2664 4.87375 12.2046 3.78275 12.2046 2.43693C12.2046 1.09112 13.2664 0.000115545 14.5759 0H29.6288Z" fill="#F94B37" />
|
||||
</svg>
|
||||
<span className="text-black text-2xl font-bold">EVERFACTORY</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-0">
|
||||
<ul>
|
||||
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
|
||||
<Link href="/usr/1_dashboard" className="flex items-center gap-3 p-2" onClick={() => setIsOpen(false)}>
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6758 3.6582C11.9147 3.44753 12.2806 3.44753 12.5195 3.6582L20.3936 10.5996C20.5258 10.7162 20.5976 10.8801 20.5977 11.0479V19.8945C20.5974 20.2155 20.3307 20.5 19.9717 20.5H4.22363C3.86456 20.5 3.59787 20.2155 3.59766 19.8945V11.0479C3.59768 10.8801 3.66953 10.7162 3.80176 10.5996L11.6758 3.6582Z" strokeWidth="2.2" className="group-hover:stroke-primary-normal stroke-black " />
|
||||
</svg>
|
||||
<span>대시보드</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
|
||||
<Link href="/usr/2_mychannel" className="flex items-center gap-2 p-2 group-hover:text-primary-normal" onClick={() => setIsOpen(false)}>
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M19.3419 4.18083C19.2127 4.12797 19.0743 4.10137 18.9347 4.10258C18.7951 4.1038 18.6572 4.1328 18.5289 4.18789C18.4007 4.24299 18.2847 4.32308 18.1877 4.42348L18.1749 4.43652L9.45656 13.1548V14.6411H10.9428L19.6611 5.92279L19.6742 5.90998C19.7746 5.813 19.8547 5.697 19.9098 5.56874C19.9649 5.44049 19.9939 5.30254 19.9951 5.16295C19.9963 5.02337 19.9697 4.88494 19.9168 4.75573C19.864 4.62654 19.7859 4.50916 19.6872 4.41045C19.5885 4.31175 19.4711 4.23369 19.3419 4.18083ZM18.9164 2.00012C19.3352 1.99648 19.7505 2.07628 20.1381 2.23485C20.5257 2.39343 20.8778 2.62761 21.1739 2.92373C21.47 3.21985 21.7042 3.57198 21.8628 3.95957C22.0214 4.34716 22.1012 4.76246 22.0975 5.18122C22.0939 5.59998 22.0069 6.01383 21.8416 6.3986C21.6777 6.78029 21.4399 7.12579 21.1421 7.41528L12.1216 16.4357C11.9245 16.6329 11.6571 16.7436 11.3783 16.7436H8.40529C7.82469 16.7436 7.35402 16.273 7.35402 15.6924V12.7194C7.35402 12.4406 7.46477 12.1732 7.66193 11.976L16.6823 2.95559C16.9718 2.65776 17.3174 2.42001 17.6991 2.25605C18.0838 2.09076 18.4977 2.00376 18.9164 2.00012ZM3.02139 5.05211C3.61284 4.46066 4.41503 4.12838 5.25147 4.12838H10.5078C11.0884 4.12838 11.5591 4.59905 11.5591 5.17965C11.5591 5.76025 11.0884 6.23092 10.5078 6.23092H5.25147C4.97266 6.23092 4.70526 6.34168 4.50811 6.53883C4.31096 6.73599 4.2002 7.00338 4.2002 7.2822V18.8462C4.2002 19.125 4.31096 19.3924 4.50811 19.5895C4.70526 19.7867 4.97266 19.8975 5.25147 19.8975H16.8155C17.0943 19.8975 17.3617 19.7867 17.5588 19.5895C17.756 19.3924 17.8667 19.125 17.8667 18.8462V13.5898C17.8667 13.0092 18.3374 12.5386 18.918 12.5386C19.4986 12.5386 19.9693 13.0092 19.9693 13.5898V18.8462C19.9693 19.6826 19.637 20.4848 19.0455 21.0763C18.4541 21.6677 17.6519 22 16.8155 22H5.25147C4.41503 22 3.61284 21.6677 3.02139 21.0763C2.42993 20.4848 2.09766 19.6826 2.09766 18.8462V7.2822C2.09766 6.44575 2.42993 5.64357 3.02139 5.05211Z" className="group-hover:fill-primary-normal stroke-none fill-black"/>
|
||||
</svg>
|
||||
<span>마이채널관리</span>
|
||||
</Link>
|
||||
</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)}>
|
||||
<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">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M2.09766 3.05263C2.09766 2.47128 2.54537 2 3.09766 2H21.0977C21.6499 2 22.0977 2.47128 22.0977 3.05263C22.0977 3.63398 21.6499 4.10526 21.0977 4.10526V15.6842C21.0977 16.2426 20.8869 16.778 20.5119 17.1729C20.1368 17.5677 19.6281 17.7895 19.0977 17.7895H14.5119L16.8048 20.203C17.1953 20.6141 17.1953 21.2806 16.8048 21.6917C16.4142 22.1028 15.7811 22.1028 15.3905 21.6917L12.0977 18.2255L8.80476 21.6917C8.41424 22.1028 7.78107 22.1028 7.39055 21.6917C7.00003 21.2806 7.00003 20.6141 7.39055 20.203L9.68344 17.7895H5.09766C4.56722 17.7895 4.05852 17.5677 3.68344 17.1729C3.30837 16.778 3.09766 16.2426 3.09766 15.6842V4.10526C2.54537 4.10526 2.09766 3.63398 2.09766 3.05263ZM5.09766 4.10526V15.6842H12.097C12.0974 15.6842 12.0979 15.6842 12.0983 15.6842H19.0977V4.10526H5.09766ZM16.0977 6.21053C16.6499 6.21053 17.0977 6.68181 17.0977 7.26316V12.5263C17.0977 13.1077 16.6499 13.5789 16.0977 13.5789C15.5454 13.5789 15.0977 13.1077 15.0977 12.5263V7.26316C15.0977 6.68181 15.5454 6.21053 16.0977 6.21053ZM12.0977 8.31579C12.6499 8.31579 13.0977 8.78707 13.0977 9.36842V12.5263C13.0977 13.1077 12.6499 13.5789 12.0977 13.5789C11.5454 13.5789 11.0977 13.1077 11.0977 12.5263V9.36842C11.0977 8.78707 11.5454 8.31579 12.0977 8.31579ZM8.09766 10.4211C8.64994 10.4211 9.09766 10.8923 9.09766 11.4737V12.5263C9.09766 13.1077 8.64994 13.5789 8.09766 13.5789C7.54537 13.5789 7.09766 13.1077 7.09766 12.5263V11.4737C7.09766 10.8923 7.54537 10.4211 8.09766 10.4211Z" className="stroke-none group-hover:fill-primary-normal fill-black" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span>정산관리</span>
|
||||
</Link>
|
||||
</li>
|
||||
<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)}>
|
||||
<div className="w-[25px] h-[24px] flex items-center justify-center">
|
||||
<svg width="20" height="17" viewBox="0 0 20 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0.446617 1.42007C1.79696 0.522928 3.58036 0 5.5 0C7.16769 0 8.73257 0.394673 10 1.08651C11.2674 0.394673 12.8323 0 14.5 0C16.4207 0 18.2031 0.522959 19.5534 1.42007C19.8323 1.60541 20 1.91809 20 2.253V15.253C20 15.6215 19.7973 15.9602 19.4726 16.1343C19.1478 16.3084 18.7536 16.2899 18.4466 16.0859C17.4609 15.431 16.0733 15 14.5 15C12.9276 15 11.539 15.4311 10.5534 16.0859C10.2181 16.3087 9.78191 16.3087 9.44662 16.0859C8.46096 15.4311 7.07236 15 5.5 15C3.92764 15 2.53904 15.4311 1.55338 16.0859C1.24644 16.2899 0.852214 16.3084 0.527445 16.1343C0.202675 15.9602 0 15.6215 0 15.253V2.253C0 1.91809 0.167658 1.60541 0.446617 1.42007ZM9 2.81949C8.06033 2.31667 6.84766 2 5.5 2C4.15234 2 2.93967 2.31667 2 2.81949V13.6261C3.0538 13.2225 4.24792 13 5.5 13C6.75208 13 7.9462 13.2225 9 13.6261V2.81949ZM11 13.6261C12.0538 13.2225 13.2479 13 14.5 13C15.7527 13 16.9465 13.2224 18 13.626V2.81947C17.0605 2.31664 15.8485 2 14.5 2C13.1523 2 11.9397 2.31667 11 2.81949V13.6261Z" className="stroke-none group-hover:fill-primary-normal fill-black" />
|
||||
</svg>
|
||||
</div>
|
||||
<span>공지 게시판</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-end mb-10">
|
||||
<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="row-[1/3] h-[44px] flex items-center justify-center">
|
||||
<Image src={user.image} alt="account" width={44} height={44} className="rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="font-bold pl-2">{user.name}</div>
|
||||
|
||||
<div className=" flex flex-row justify-between pointer text-sm cursor-pointer rounded-sm pl-1">
|
||||
<span className="text-sm text-[#848484] pl-1.5">
|
||||
{user.email}
|
||||
</span>
|
||||
{/* <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 15L12.5 10L7.5 5" stroke="#848484" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg> */}
|
||||
</div>
|
||||
|
||||
<div className="row-[1/3] col-[4/4] "></div>
|
||||
|
||||
<div className="h-[5px] col-[2/4]"></div>
|
||||
<div className="border-1 border-[#D5D5D5] col-[1/5] h-[54px] rounded-lg flex content-center items-center justify-center cursor-pointer hover:bg-[#F0F0F0] group" onClick={() => signOut({callbackUrl: "/"})}>
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.14062 12.6406L1.50005 6.99999L7.14063 1.35941" className="stroke-[#848484] group-hover:stroke-black" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M1.50006 7L13.5 7" className="stroke-[#848484] group-hover:stroke-[#000000] " strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M1 1V13" className="stroke-[#848484] group-hover:stroke-black" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="text-md font-bold text-[#848484] group-hover:text-black">로그아웃</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBar;
|
||||
8
app/components/SignInClient.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { signIn } from "next-auth/react"
|
||||
|
||||
export default function SignIn() {
|
||||
return <button onClick={() => signIn("google")}>
|
||||
</button>
|
||||
}
|
||||
73
app/components/editor.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import { Crepe as CrepeClass } from "@milkdown/crepe";
|
||||
import { Milkdown, useEditor } from "@milkdown/react";
|
||||
import { Decoration } from "@milkdown/prose/view";
|
||||
import { upload, uploadConfig, type Uploader } from "@milkdown/plugin-upload";
|
||||
import type { Node as PMNode } from "@milkdown/prose/model";
|
||||
|
||||
// (선택) UI 스타일
|
||||
import "@milkdown/crepe/theme/common/style.css";
|
||||
import "@milkdown/crepe/theme/frame.css";
|
||||
|
||||
async function uploadToServer(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data?.error ?? "UPLOAD_FAILED");
|
||||
return data.url as string; // 예: /uploads/xxx.png
|
||||
}
|
||||
|
||||
export const MilkdownEditor: FC<{
|
||||
editorRef?: React.RefObject<CrepeClass>;
|
||||
}> = ({ editorRef }) => {
|
||||
useEditor((root) => {
|
||||
const crepe = new CrepeClass({
|
||||
root,
|
||||
defaultValue: "여기에 내용을 입력",
|
||||
featureConfigs: {
|
||||
[CrepeClass.Feature.ImageBlock]: {
|
||||
// ✅ 이 버전에선 string(URL)만 반환
|
||||
onUpload: async (file: File) => {
|
||||
const url = await uploadToServer(file);
|
||||
return url;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (editorRef) editorRef.current = crepe;
|
||||
|
||||
const uploader: Uploader = async (files, schema) => {
|
||||
const nodes: PMNode[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files.item(i);
|
||||
if (!f || !f.type.startsWith("image/")) continue;
|
||||
const url = await uploadToServer(f);
|
||||
const node = schema.nodes.image.createAndFill({ src: url, alt: f.name, title: "" });
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
crepe.editor
|
||||
.config((ctx) => {
|
||||
ctx.set(uploadConfig.key, {
|
||||
enableHtmlFileUploader: true,
|
||||
uploadWidgetFactory: (pos) =>
|
||||
Decoration.widget(pos, () => {
|
||||
const el = document.createElement("span");
|
||||
el.className = "md-upload-placeholder";
|
||||
return el;
|
||||
}),
|
||||
uploader, // 붙여넣기/드래그 업로드
|
||||
});
|
||||
})
|
||||
.use(upload);
|
||||
|
||||
return crepe.editor;
|
||||
}, []);
|
||||
|
||||
return <Milkdown />;
|
||||
};
|
||||
7
app/components/svgs/arrow.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Arrow({ color, width, height }: { color: string, width: number, height: number }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
8
app/components/svgs/dayRange.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function DayRange({color, width, height}: {color: string, width: number, height: number}) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M8 2C8.55228 2 9 2.44772 9 3V4H15V3C15 2.44772 15.4477 2 16 2C16.5523 2 17 2.44772 17 3V4H19C19.7957 4 20.5587 4.31607 21.1213 4.87868C21.6839 5.44129 22 6.20435 22 7V19C22 19.7957 21.6839 20.5587 21.1213 21.1213C20.5587 21.6839 19.7957 22 19 22H5C4.20435 22 3.44129 21.6839 2.87868 21.1213C2.31607 20.5587 2 19.7957 2 19V7C2 6.20435 2.31607 5.44129 2.87868 4.87868C3.44129 4.31607 4.20435 4 5 4H7V3C7 2.44772 7.44772 2 8 2ZM7 6H5C4.73478 6 4.48043 6.10536 4.29289 6.29289C4.10536 6.48043 4 6.73478 4 7V19C4 19.2652 4.10536 19.5196 4.29289 19.7071C4.48043 19.8946 4.73478 20 5 20H19C19.2652 20 19.5196 19.8946 19.7071 19.7071C19.8946 19.5196 20 19.2652 20 19V7C20 6.73478 19.8946 6.48043 19.7071 6.29289C19.5196 6.10536 19.2652 6 19 6H17V7C17 7.55228 16.5523 8 16 8C15.4477 8 15 7.55228 15 7V6H9V7C9 7.55228 8.55228 8 8 8C7.44772 8 7 7.55228 7 7V6ZM6 11C6 10.4477 6.44772 10 7 10H8C8.55229 10 9 10.4477 9 11C9 11.5523 8.55229 12 8 12H7C6.44772 12 6 11.5523 6 11Z" fill={color} />
|
||||
<path d="M14.5 17.5C14.5 16.9477 14.9477 16.5 15.5 16.5H16.5C17.0523 16.5 17.5 16.9477 17.5 17.5C17.5 18.0523 17.0523 18.5 16.5 18.5H15.5C14.9477 18.5 14.5 18.0523 14.5 17.5Z" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
7
app/components/svgs/oneMonth.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function OneMonth({ color, width, height }: { color: string, width: number, height: number }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M8 2C8.55228 2 9 2.44772 9 3V4H15V3C15 2.44772 15.4477 2 16 2C16.5523 2 17 2.44772 17 3V4H19C19.7957 4 20.5587 4.31607 21.1213 4.87868C21.6839 5.44129 22 6.20435 22 7V19C22 19.7957 21.6839 20.5587 21.1213 21.1213C20.5587 21.6839 19.7957 22 19 22H5C4.20435 22 3.44129 21.6839 2.87868 21.1213C2.31607 20.5587 2 19.7957 2 19V7C2 6.20435 2.31607 5.44129 2.87868 4.87868C3.44129 4.31607 4.20435 4 5 4H7V3C7 2.44772 7.44772 2 8 2ZM7 6H5C4.73478 6 4.48043 6.10536 4.29289 6.29289C4.10536 6.48043 4 6.73478 4 7V19C4 19.2652 4.10536 19.5196 4.29289 19.7071C4.48043 19.8946 4.73478 20 5 20H19C19.2652 20 19.5196 19.8946 19.7071 19.7071C19.8946 19.5196 20 19.2652 20 19V7C20 6.73478 19.8946 6.48043 19.7071 6.29289C19.5196 6.10536 19.2652 6 19 6H17V7C17 7.55228 16.5523 8 16 8C15.4477 8 15 7.55228 15 7V6H9V7C9 7.55228 8.55228 8 8 8C7.44772 8 7 7.55228 7 7V6ZM6 12C6 10.8954 6.89543 10 8 10H16C17.1046 10 18 10.8954 18 12V16C18 17.1046 17.1074 18 16.0028 18C13.7961 18 10.2567 18 7.99962 18C6.89505 18 6 17.1046 6 16V12Z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
7
app/components/svgs/realtime.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Realtime({ color, width, height }: { color: string, width: number, height: number }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M10 2C7.87827 2 5.84344 2.84285 4.34315 4.34315C2.84285 5.84344 2 7.87827 2 10C2 11.0506 2.20693 12.0909 2.60896 13.0615C3.011 14.0321 3.60028 14.914 4.34315 15.6569C5.08601 16.3997 5.96793 16.989 6.93853 17.391C7.90914 17.7931 8.94942 18 10 18C11.0506 18 12.0909 17.7931 13.0615 17.391C14.0321 16.989 14.914 16.3997 15.6569 15.6569C16.3997 14.914 16.989 14.0321 17.391 13.0615C17.7931 12.0909 18 11.0506 18 10C18 7.87827 17.1571 5.84344 15.6569 4.34315C14.1566 2.84285 12.1217 2 10 2ZM2.92893 2.92893C4.8043 1.05357 7.34784 0 10 0C12.6522 0 15.1957 1.05357 17.0711 2.92893C18.9464 4.8043 20 7.34784 20 10C20 11.3132 19.7413 12.6136 19.2388 13.8268C18.7362 15.0401 17.9997 16.1425 17.0711 17.0711C16.1425 17.9997 15.0401 18.7362 13.8268 19.2388C12.6136 19.7413 11.3132 20 10 20C8.68678 20 7.38642 19.7413 6.17317 19.2388C4.95991 18.7362 3.85752 17.9997 2.92893 17.0711C2.00035 16.1425 1.26375 15.0401 0.761205 13.8268C0.258658 12.6136 0 11.3132 0 10C5.96046e-08 7.34784 1.05357 4.8043 2.92893 2.92893ZM10 5C10.5523 5 11 5.44772 11 6V9.58579L13.7071 12.2929C14.0976 12.6834 14.0976 13.3166 13.7071 13.7071C13.3166 14.0976 12.6834 14.0976 12.2929 13.7071L9.29289 10.7071C9.10536 10.5196 9 10.2652 9 10V6C9 5.44772 9.44771 5 10 5Z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
7
app/components/svgs/svgChannelFilter.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Realtime({ color, width, height }: { color: string, width: number, height: number }) {
|
||||
return (
|
||||
<svg width={width} height={width} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M2 5C2 4.44772 2.39797 4 2.88889 4H17.1111C17.602 4 18 4.44772 18 5C18 5.55228 17.602 6 17.1111 6H2.88889C2.39797 6 2 5.55228 2 5ZM3.11111 11C3.11111 10.4477 3.50908 10 4 10H16C16.4909 10 16.8889 10.4477 16.8889 11C16.8889 11.5523 16.4909 12 16 12H4C3.50908 12 3.11111 11.5523 3.11111 11ZM4.22222 17C4.22222 16.4477 4.62019 16 5.11111 16H14.8889C15.3798 16 15.7778 16.4477 15.7778 17C15.7778 17.5523 15.3798 18 14.8889 18H5.11111C4.62019 18 4.22222 17.5523 4.22222 17Z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
15
app/constants/dateRange.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// 날짜 범위 공용 Enum (다른 컴포넌트에서 재사용)
|
||||
export enum DateRangeEnum {
|
||||
ALL= 1,
|
||||
ONE_MONTH= 2,
|
||||
DAY_RANGE = 3,
|
||||
}
|
||||
|
||||
// 필요 시 값 배열 등 유틸도 함께 노출 가능
|
||||
export const DATE_RANGE_VALUES = [
|
||||
DateRangeEnum.ALL,
|
||||
DateRangeEnum.ONE_MONTH,
|
||||
DateRangeEnum.DAY_RANGE,
|
||||
] as const;
|
||||
|
||||
|
||||
BIN
app/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
94
app/fonts/SUIT/LICENSE
Normal file
@@ -0,0 +1,94 @@
|
||||
Copyright (c) 2022, SUNN (http://sun.fo/suit),
|
||||
with Reserved Font Name SUIT.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
app/fonts/SUIT/SUIT-Bold.ttf
Normal file
BIN
app/fonts/SUIT/SUIT-ExtraBold.ttf
Normal file
BIN
app/fonts/SUIT/SUIT-ExtraLight.ttf
Normal file
BIN
app/fonts/SUIT/SUIT-Heavy.ttf
Normal file
BIN
app/fonts/SUIT/SUIT-Light.ttf
Normal file
BIN
app/fonts/SUIT/SUIT-Medium.ttf
Normal file
BIN
app/fonts/SUIT/SUIT-Regular.ttf
Normal file
BIN
app/fonts/SUIT/SUIT-SemiBold.ttf
Normal file
BIN
app/fonts/SUIT/SUIT-Thin.ttf
Normal file
52
app/fontsuit.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// app/fonts.ts
|
||||
import localFont from "next/font/local";
|
||||
|
||||
export const fontsuit = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Thin.ttf",
|
||||
weight: "100",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-ExtraLight.ttf",
|
||||
weight: "200",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Light.ttf",
|
||||
weight: "300",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Regular.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Medium.ttf",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-SemiBold.ttf",
|
||||
weight: "600",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Bold.ttf",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-ExtraBold.ttf",
|
||||
weight: "800",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Heavy.ttf",
|
||||
weight: "900",
|
||||
style: "normal",
|
||||
},
|
||||
]
|
||||
});
|
||||
172
app/globals.css
Normal file
@@ -0,0 +1,172 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
|
||||
@theme {
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #171717;
|
||||
--color-primary-normal: #F94B37;
|
||||
--color-primary-strong: #D73B29;
|
||||
--color-primary-pale: #FFF3F2;
|
||||
--color-border-pale: #d5d5d5;
|
||||
}
|
||||
|
||||
@layer base{
|
||||
html,body{
|
||||
@apply h-full w-full;
|
||||
@apply min-w-[500px] min-h-[820px];
|
||||
@apply bg-[#F5F5F5];
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
body{
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components{
|
||||
.btn-primary{
|
||||
@apply bg-primary-normal text-white px-4 py-2 rounded-md;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities{
|
||||
.btn-primary{
|
||||
@apply bg-primary-normal text-white px-4 py-2 rounded-md;
|
||||
}
|
||||
.transition-width {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
.sidebar-menu-hover:hover{
|
||||
@apply bg-primary-pale rounded-lg text-primary-normal;
|
||||
}
|
||||
.right-border{
|
||||
@apply relative after:content-[''] after:absolute after:right-0 after:top-[10px] after:bottom-[10px] after:w-px after:bg-gray-300;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(50%); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
.animate-marquee {
|
||||
animation: marquee var(--marquee-duration, 15s) linear infinite;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.milkdown .ProseMirror {
|
||||
position: relative; /* 부모 요소에 상대 위치를 설정 */
|
||||
padding: 0px 0px 0px 80px !important;
|
||||
}
|
||||
|
||||
.milkdown .ProseMirror::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 70px; /* 왼쪽에서 80px 위치 */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px; /* 선의 두께 */
|
||||
background-color: #d5d5d5; /* 선의 색상 */
|
||||
}
|
||||
|
||||
.milkviewer .milkdown .ProseMirror {
|
||||
position: relative; /* 부모 요소에 상대 위치를 설정 */
|
||||
padding: 0px 0px 0px 10px !important;
|
||||
}
|
||||
|
||||
.milkviewer .milkdown .ProseMirror::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0px; /* 왼쪽에서 80px 위치 */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0px; /* 선의 두께 */
|
||||
background-color: #000000; /* 선의 색상 */
|
||||
}
|
||||
|
||||
|
||||
.milkdown-slash-menu{
|
||||
z-index: 100 !important;
|
||||
position: fixed !important;
|
||||
}
|
||||
|
||||
|
||||
.content-col{
|
||||
margin-right: 4px;
|
||||
padding: 1px;
|
||||
height:22px;
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
border-right: 1px solid #a6a9ae;
|
||||
}
|
||||
|
||||
.content-col:last-child{
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SUIT';
|
||||
src: url('/app/fonts/SUIT/SUIT-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SUIT';
|
||||
src: url('/app/fonts/SUIT/SUIT-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SUIT';
|
||||
src: url('/app/fonts/SUIT/SUIT-ExtraBold.ttf') format('truetype');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SUIT';
|
||||
src: url('/app/fonts/SUIT/SUIT-ExtraLight.ttf') format('truetype');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SUIT';
|
||||
src: url('/app/fonts/SUIT/SUIT-Heavy.ttf') format('truetype');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SUIT';
|
||||
src: url('/app/fonts/SUIT/SUIT-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SUIT';
|
||||
src: url('/app/fonts/SUIT/SUIT-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SUIT';
|
||||
src: url('/app/fonts/SUIT/SUIT-SemiBold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SUIT';
|
||||
src: url('/app/fonts/SUIT/SUIT-Thin.ttf') format('truetype');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.milkdown {
|
||||
--crepe-font-title: 'SUIT' ;
|
||||
--crepe-font-default: 'SUIT' ;
|
||||
--crepe-font-code: 'SUIT' ;
|
||||
}
|
||||
78
app/layout.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import localFont from "next/font/local";
|
||||
import "@milkdown/crepe/theme/common/style.css";
|
||||
import "@milkdown/crepe/theme/frame.css";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "everfactory",
|
||||
description: "easy music production",
|
||||
};
|
||||
|
||||
const fontsuit = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Thin.ttf",
|
||||
weight: "100",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-ExtraLight.ttf",
|
||||
weight: "200",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Light.ttf",
|
||||
weight: "300",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Regular.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Medium.ttf",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-SemiBold.ttf",
|
||||
weight: "600",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Bold.ttf",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-ExtraBold.ttf",
|
||||
weight: "800",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./fonts/SUIT/SUIT-Heavy.ttf",
|
||||
weight: "900",
|
||||
style: "normal",
|
||||
},
|
||||
|
||||
],
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${fontsuit.className} antialiased`} >
|
||||
<SessionProvider>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
13
app/login/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import SignIn from "@/app/components/SignInClient";
|
||||
|
||||
export default function Test() {
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SignIn/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
app/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
import Image from 'next/image';
|
||||
import { signIn } from "next-auth/react"
|
||||
|
||||
|
||||
|
||||
export default function Home() {
|
||||
const handleSignIn = () => {
|
||||
signIn("google")
|
||||
}
|
||||
return (
|
||||
<div className="bg-[url('/imsi_bg.png')] bg-cover bg-center h-screen">
|
||||
{/* <video className="bg-cover bg-center h-screen" autoPlay loop muted>
|
||||
<source src="/bgmv.mp4" type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video> */}
|
||||
<div className="
|
||||
h-screen
|
||||
pt-5 pb-5
|
||||
|
||||
w-full sm:w-1/2 xl:w-1/3
|
||||
transition-width
|
||||
|
||||
fixed right-0
|
||||
shadow-lg
|
||||
|
||||
min-w-[500px] min-h-[500px]
|
||||
" >
|
||||
<div
|
||||
className="
|
||||
bg-black/30 backdrop-blur-lg
|
||||
|
||||
w-full h-full
|
||||
rounded-tl-xl rounded-bl-xl
|
||||
flex flex-col items-center justify-center
|
||||
p-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">
|
||||
<Image src="/ever_logo.png" alt="logo" width={100} height={100} />
|
||||
</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>
|
||||
<div>
|
||||
<div className="text-white text-md text-center mb-3 cursor-pointer ">Ready to travel with us?<br/> Enter your email to create or restart your membership.</div>
|
||||
<div className="pointer rounded-full bg-white p-2 flex items-center justify-center gap-2 cursor-pointer" onClick={handleSignIn}> <FcGoogle/> continue with google</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
app/privacy/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
// app/privacy/page.tsx
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "개인정보 처리방침 | Talkbaram",
|
||||
description: "Talkbaram의 개인정보 처리방침",
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{ id: "intro", title: "1. 총칙" },
|
||||
{ id: "collect", title: "2. 수집하는 개인정보 항목과 방법" },
|
||||
{ id: "use", title: "3. 개인정보의 이용 목적" },
|
||||
{ id: "retention", title: "4. 보유 및 이용기간" },
|
||||
{ id: "third", title: "5. 제3자 제공 및 처리위탁" },
|
||||
{ id: "rights", title: "6. 이용자 및 법정대리인의 권리" },
|
||||
{ id: "security", title: "7. 안전성 확보조치" },
|
||||
{ id: "overseas", title: "8. 국외 이전(해당 시)" },
|
||||
{ id: "contact", title: "9. 문의처" },
|
||||
{ id: "update", title: "10. 고지의 의무" },
|
||||
];
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<main className="h-screen bg-white overflow-y-auto">
|
||||
<section className="mx-auto max-w-3xl px-4 py-12">
|
||||
{/* Header */}
|
||||
<header className="mb-8 rounded-2xl border bg-gray-50 p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-bold tracking-tight">개인정보 처리방침</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
everflow(이하 “회사”)은 이용자의 개인정보를 소중히 여기며 관련 법령을 준수합니다.
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-2 rounded-xl bg-white px-3 py-1.5 text-xs text-gray-500 ring-1 ring-gray-200">
|
||||
<span>시행일</span>
|
||||
<span className="font-medium">2025-08-01</span>
|
||||
<span className="mx-1">•</span>
|
||||
<span>최종 업데이트</span>
|
||||
<span className="font-medium">2025-08-20</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* TOC */}
|
||||
<nav className="mb-10 grid gap-2 rounded-2xl border p-4 text-sm shadow-sm">
|
||||
<span className="mb-1 font-semibold text-gray-700">목차</span>
|
||||
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
{sections.map((s) => (
|
||||
<li key={s.id}>
|
||||
<a
|
||||
href={`#${s.id}`}
|
||||
className="inline-block rounded-lg px-2 py-1 hover:bg-gray-50 hover:text-gray-900 text-gray-600"
|
||||
>
|
||||
{s.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Body */}
|
||||
<article className="space-y-10">
|
||||
<section id="intro" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[0].title}</h2>
|
||||
<p className="mt-3 leading-7 text-gray-700">
|
||||
본 처리방침은 서비스 제공과정에서 처리되는 개인정보의 수집·이용·보관·파기 등 전반을 설명합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="collect" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[1].title}</h2>
|
||||
<div className="mt-3 grid gap-4">
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3 className="font-medium">수집 항목(필수)</h3>
|
||||
<ul className="mt-2 list-disc pl-5 text-gray-700">
|
||||
<li>계정: 이메일, 비밀번호(또는 OAuth 식별자)</li>
|
||||
<li>서비스 이용기록: 접속 일시, IP, 기기/브라우저 정보</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3 className="font-medium">수집 항목(선택)</h3>
|
||||
<ul className="mt-2 list-disc pl-5 text-gray-700">
|
||||
<li>프로필: 이름, 프로필 이미지, 연락처</li>
|
||||
<li>마케팅 수신 동의 여부</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3 className="font-medium">수집 방법</h3>
|
||||
<p className="mt-2 text-gray-700">
|
||||
회원가입, 서비스 이용 중 생성, 고객문의 처리, 제휴를 통한 수집(사전 동의 기반) 등
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="use" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[2].title}</h2>
|
||||
<ul className="mt-3 list-disc pl-5 text-gray-700 leading-7">
|
||||
<li>서비스 제공 및 유지·관리</li>
|
||||
<li>고객문의 응대, 공지사항 전달</li>
|
||||
<li>보안, 부정사용 방지, 서비스 품질 개선 및 통계</li>
|
||||
<li>이벤트/마케팅 안내(별도 동의 시)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="retention" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[3].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
원칙적으로 목적 달성 시 즉시 파기합니다. 단, 관계 법령에 따라 아래 기간 보관할 수 있습니다.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-gray-700">
|
||||
<li>계약/청약철회 기록: 5년</li>
|
||||
<li>대금 결제 및 재화 공급 기록: 5년</li>
|
||||
<li>소비자 불만 또는 분쟁 처리 기록: 3년</li>
|
||||
<li>로그기록/IP 등 통신사실 확인자료: 3개월</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="third" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[4].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
법령 근거 또는 이용자 동의가 있는 경우에 한해 제3자 제공 또는 처리위탁을 할 수 있습니다.
|
||||
</p>
|
||||
<div className="mt-3 rounded-xl border p-4">
|
||||
<h3 className="font-medium">처리위탁(예시)</h3>
|
||||
<ul className="mt-2 list-disc pl-5 text-gray-700">
|
||||
<li>인프라 운영: 클라우드 호스팅 사업자</li>
|
||||
<li>알림/이메일 발송: 메시징 서비스 사업자</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="rights" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[5].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
이용자는 언제든지 개인정보 조회·수정·삭제·처리정지를 요청할 수 있습니다. 계정 메뉴 또는 아래
|
||||
문의처를 통해 요청하실 수 있습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="security" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[6].title}</h2>
|
||||
<ul className="mt-3 list-disc pl-5 text-gray-700">
|
||||
<li>암호화 전송 및 저장</li>
|
||||
<li>접근권한 최소화 및 접근통제</li>
|
||||
<li>정기적 보안 점검 및 교육</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="overseas" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[7].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
국외 이전이 필요한 경우 이전받는 자, 이전국가, 이전 일시/방법, 보유·이용기간 등을 사전에 고지하고 동의를 받습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="contact" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[8].title}</h2>
|
||||
<div className="mt-3 rounded-xl border p-4">
|
||||
<p className="text-gray-700">
|
||||
개인정보 보호책임자: <span className="font-medium">홍길동</span>
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
이메일: <a href="mailto:privacy@talkbaram.com" className="underline">privacy@talkbaram.com</a>
|
||||
</p>
|
||||
<p className="text-gray-700">주소: 서울특별시 …</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="update" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[9].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
본 처리방침이 변경되는 경우, 시행 7일 전(중요 변경 시 30일 전) 서비스 공지사항을 통해 안내합니다.
|
||||
</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* Footer CTA */}
|
||||
<footer className="mt-12 flex items-center justify-between rounded-2xl border bg-gray-50 p-5">
|
||||
<span className="text-sm text-gray-600">
|
||||
본 문서는 안내용 템플릿입니다. 서비스 실정에 맞게 수정하세요.
|
||||
</span>
|
||||
<a
|
||||
href="/terms"
|
||||
className="rounded-xl bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-black"
|
||||
>
|
||||
서비스 약관 보기
|
||||
</a>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
174
app/term/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "서비스 이용약관 | Talkbaram",
|
||||
description: "Talkbaram의 서비스 이용약관",
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{ id: "purpose", title: "1. 목적" },
|
||||
{ id: "def", title: "2. 용어의 정의" },
|
||||
{ id: "account", title: "3. 계정 및 인증" },
|
||||
{ id: "service", title: "4. 서비스의 제공 및 변경" },
|
||||
{ id: "user-duty", title: "5. 이용자의 의무" },
|
||||
{ id: "fee", title: "6. 요금·결제·환불" },
|
||||
{ id: "content", title: "7. 콘텐츠의 권리와 책임" },
|
||||
{ id: "restriction", title: "8. 이용제한" },
|
||||
{ id: "disclaimer", title: "9. 면책" },
|
||||
{ id: "governing", title: "10. 준거법 및 분쟁" },
|
||||
{ id: "notice", title: "11. 고지 및 약관변경" },
|
||||
{ id: "contact", title: "12. 문의처" },
|
||||
];
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<main className=" h-screen bg-white overflow-y-auto">
|
||||
<section className="mx-auto max-w-3xl px-4 py-12">
|
||||
{/* Header */}
|
||||
<header className="mb-8 rounded-2xl border bg-gray-50 p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-bold tracking-tight">서비스 이용약관</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
이 약관은 everflow(이하 “회사”)가 제공하는 서비스 이용과 관련하여 회사와 이용자의 권리·의무 및 책임사항을 규정합니다.
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-2 rounded-xl bg-white px-3 py-1.5 text-xs text-gray-500 ring-1 ring-gray-200">
|
||||
<span>시행일</span>
|
||||
<span className="font-medium">2025-08-01</span>
|
||||
<span className="mx-1">•</span>
|
||||
<span>최종 업데이트</span>
|
||||
<span className="font-medium">2025-08-20</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* TOC */}
|
||||
<nav className="mb-10 grid gap-2 rounded-2xl border p-4 text-sm shadow-sm">
|
||||
<span className="mb-1 font-semibold text-gray-700">목차</span>
|
||||
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
{sections.map((s) => (
|
||||
<li key={s.id}>
|
||||
<a
|
||||
href={`#${s.id}`}
|
||||
className="inline-block rounded-lg px-2 py-1 hover:bg-gray-50 hover:text-gray-900 text-gray-600"
|
||||
>
|
||||
{s.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Body */}
|
||||
<article className="space-y-10">
|
||||
<section id="purpose" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[0].title}</h2>
|
||||
<p className="mt-3 leading-7 text-gray-700">
|
||||
본 약관은 회사가 제공하는 모든 온라인/모바일 서비스의 이용에 적용됩니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="def" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[1].title}</h2>
|
||||
<ul className="mt-3 list-disc pl-5 text-gray-700">
|
||||
<li>“서비스”: 회사가 제공하는 모든 제품 및 기능</li>
|
||||
<li>“이용자”: 약관에 동의하고 서비스를 이용하는 자</li>
|
||||
<li>“콘텐츠”: 이용자가 서비스에 업로드하거나 생성한 정보</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="account" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[2].title}</h2>
|
||||
<ul className="mt-3 list-disc pl-5 text-gray-700">
|
||||
<li>이용자는 정확한 정보를 제공하고, 계정 보안을 유지해야 합니다.</li>
|
||||
<li>OAuth 계정 연동 시 해당 제공자의 약관도 적용됩니다.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="service" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[3].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
회사는 안정적인 서비스를 위해 변경·중단·점검을 실시할 수 있으며, 중요한 변경은 사전 고지합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="user-duty" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[4].title}</h2>
|
||||
<ul className="mt-3 list-disc pl-5 text-gray-700">
|
||||
<li>법령, 약관, 공지 준수</li>
|
||||
<li>타인의 권리를 침해하는 행위 금지(저작권, 개인정보 등)</li>
|
||||
<li>서비스의 비정상적 이용(역컴파일, 자동화 남용 등) 금지</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="fee" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[5].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
유료 서비스의 요금, 결제수단, 환불 기준은 별도 고지 또는 결제 화면에 따릅니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="content" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[6].title}</h2>
|
||||
<ul className="mt-3 list-disc pl-5 text-gray-700">
|
||||
<li>이용자는 자신의 콘텐츠에 대한 권리를 보유합니다.</li>
|
||||
<li>
|
||||
서비스 제공·운영·개선을 위한 범위 내에서의 이용 허가(비독점·무상·전세계적 라이선스)를 회사에 부여합니다.
|
||||
</li>
|
||||
<li>불법·유해 콘텐츠는 사전 통지 없이 제한될 수 있습니다.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="restriction" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[7].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
회사는 약관 위반, 보안 위험, 권리침해 신고 등 합리적 사유가 있는 경우 이용을 제한할 수 있습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="disclaimer" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[8].title}</h2>
|
||||
<ul className="mt-3 list-disc pl-5 text-gray-700">
|
||||
<li>천재지변, 네트워크 장애 등 불가항력에 대한 책임 제한</li>
|
||||
<li>이용자의 귀책 사유로 인한 손해에 대한 책임 제한</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="governing" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[9].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
본 약관은 대한민국 법률에 따르며, 분쟁은 민사소송법상 관할 법원에 제기합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="notice" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[10].title}</h2>
|
||||
<p className="mt-3 text-gray-700">
|
||||
약관의 변경은 시행 7일 전(중요 변경 시 30일 전) 공지합니다. 변경 이후에도 서비스를 계속 이용하면 변경에 동의한 것으로 봅니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="contact" className="scroll-mt-24">
|
||||
<h2 className="text-xl font-semibold">{sections[11].title}</h2>
|
||||
<div className="mt-3 rounded-xl border p-4">
|
||||
<p className="text-gray-700">
|
||||
고객센터: <a href="mailto:support@talkbaram.com" className="underline">support@talkbaram.com</a>
|
||||
</p>
|
||||
<p className="text-gray-700">운영시간: 평일 10:00 ~ 17:00 (점심 12:00 ~ 13:00)</p>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* Footer CTA */}
|
||||
<footer className="mt-12 flex items-center justify-between rounded-2xl border bg-gray-50 p-5">
|
||||
<span className="text-sm text-gray-600">
|
||||
최신 약관은 본 페이지에서 확인하실 수 있습니다.
|
||||
</span>
|
||||
<a
|
||||
href="/privacy"
|
||||
className="rounded-xl bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-black"
|
||||
>
|
||||
개인정보 처리방침 보기
|
||||
</a>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
24
app/usr/1_dashboard/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
// app/dashboard/page.tsx (Server Component)
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import DashboardClient from "@/app/components/Dashboard"; // 기존 코드 대부분 이동
|
||||
|
||||
export default async function Page() {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
// 초기 데이터 서버에서 미리 가져오면 더 빠르고 안전 (예시)
|
||||
// const [channels, contents] = await Promise.all([
|
||||
// fetch(`${process.env.API_URL}/channels`, { cache: "no-store" }).then(r=>r.json()),
|
||||
// fetch(`${process.env.API_URL}/contents`, { cache: "no-store" }).then(r=>r.json()),
|
||||
// ]);
|
||||
|
||||
return (
|
||||
<DashboardClient
|
||||
// initialChannels={channels}
|
||||
// initialContents={contents}
|
||||
user={session.user} // id/email/name 등
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
236
app/usr/2_mychannel/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
import CalenderSelector from "@/app/components/CalenderSelector";
|
||||
import Modal from "@/app/components/Modal";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
|
||||
export default function Page() {
|
||||
const [isreqmodal, setIsreqmodal] = useState(false);
|
||||
const [chanellist, setChanellist] = useState<string[]>([]);
|
||||
const [contentlist, setContentlist] = useState<{
|
||||
id: string;
|
||||
email: string;
|
||||
pubDate: string;
|
||||
is_approved: boolean;
|
||||
is_certified: boolean;
|
||||
video_count: number;
|
||||
views: number;
|
||||
revenue: number;
|
||||
pendingRevenue: number;
|
||||
}[]>([]);
|
||||
|
||||
const [channelList, setChannelList] = useState<{
|
||||
id: string;
|
||||
handle: string;
|
||||
createtime: string;
|
||||
is_approved: boolean;
|
||||
icon: string;
|
||||
}[]>([]);
|
||||
|
||||
|
||||
const [registerCode, setRegisterCode] = useState<string>("");
|
||||
|
||||
const handleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
||||
const formatNumberWithCommas = (number: number): string => {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
};
|
||||
|
||||
const get_code = async () => {
|
||||
if (handleInputRef.current && handleInputRef.current.value) {
|
||||
const inputValue = handleInputRef.current.value;
|
||||
if (confirm(`핸들 "${inputValue}" 의 등록 코드를 발급하시겠습니까?`)) {
|
||||
try {
|
||||
const response = await fetch(`/api/channel_code?handle=${inputValue}`);
|
||||
if (response.status === 409) {
|
||||
alert("이미 등록된 요청입니다. 기존 코드를 사용합니다.");
|
||||
const data = await response.json();
|
||||
setRegisterCode(data.randomcode);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('서버 응답이 올바르지 않습니다.');
|
||||
}
|
||||
const data = await response.json();
|
||||
setRegisterCode(data.randomcode);
|
||||
} catch (error) {
|
||||
console.error('채널 코드 요청 중 오류 발생:', error);
|
||||
alert('채널 코드를 가져오는데 실패했습니다. 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else{ alert("채널 핸들을 입력해주세요"); }
|
||||
}
|
||||
|
||||
const register_channel = async () => {
|
||||
if (handleInputRef.current && handleInputRef.current.value) {
|
||||
const inputValue = handleInputRef.current.value;
|
||||
try {
|
||||
const response = await fetch(`/api/register_channel?handle=${encodeURIComponent(inputValue)}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
alert(data?.error ?? '등록 요청 실패');
|
||||
return;
|
||||
}
|
||||
alert('등록 요청을 전송했습니다.');
|
||||
setIsreqmodal(false);
|
||||
} catch (error) {
|
||||
console.error('등록 요청 중 오류:', error);
|
||||
alert('등록 요청 실패');
|
||||
}
|
||||
} else { alert("채널 핸들을 입력해주세요"); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const fetchListChannel = async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/list_channel', { cache: 'no-store' });
|
||||
const data = await resp.json();
|
||||
setChannelList(data.items);
|
||||
console.log('list_channel:', data);
|
||||
} catch (e) {
|
||||
console.error('list_channel 요청 에러:', e);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 세션 이메일 기준 채널 목록 조회 로그
|
||||
fetchListChannel();
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-[100vw] p-2 lg:w-[calc(100vw-350px)] max-w-[1200px] mx-auto">
|
||||
<div className="flex flex-row h-[64px] justify-between items-center">
|
||||
<div className="flex flex-row h-[36px]">
|
||||
|
||||
</div>
|
||||
<div className="flex flex-row h-[36px]">
|
||||
<div
|
||||
className="w-[104px] h-[36px] rounded-lg flex items-center justify-center bg-[#F94B37] border-[#D73B29] text-white cursor-pointer hover:bg-[#D73B29]"
|
||||
onClick={() => {setIsreqmodal(true); setRegisterCode("")}}
|
||||
>
|
||||
채널 등록
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="
|
||||
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]">
|
||||
<colgroup>
|
||||
<col className="min-w-[250px] max-w-[300px]"/>
|
||||
<col className="w-[120px]"/>
|
||||
<col className="w-[80px]"/>
|
||||
<col className="w-[100px]"/>
|
||||
<col className="w-[100px]"/>
|
||||
<col className="w-[110px]"/>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<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 border-[#e6e9ef] ">예상수익</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
channelList.map((channel)=>{
|
||||
return (
|
||||
<tr key={channel.id} className="h-[54px] border-1 border-[#e6e9ef] rounded-lg font-semibold">
|
||||
<td className="right-border rounded-l-lg border-l-1 border-t-1 border-b-1 border-[#e6e9ef] pl-2 h-[54px] " >
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="w-[48px] h-[48px] rounded-full border-1 border-[#e6e9ef]">
|
||||
<img src={channel.icon} alt="channel icon" className="w-[48px] h-[48px] rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
{channel.handle}
|
||||
</div>
|
||||
</div>
|
||||
</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"> - </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-[#e6e9ef] border-r-1 rounded-r-lg text-center"> - </td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Modal isOpen={isreqmodal} onClose={() => setIsreqmodal(false)}>
|
||||
<div className=" w-full h-full flex flex-col justify-start gap-5">
|
||||
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="text-3xl font-semibold">채널등록</div>
|
||||
<div className="text-3xl cursor-pointer" onClick={() => setIsreqmodal(false)}>
|
||||
<svg width="18" height="17" viewBox="0 0 18 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0.951569 0.451569C1.4202 -0.0170597 2.18 -0.0170597 2.64863 0.451569L9.0001 6.80304L15.3516 0.451569C15.8202 -0.0170597 16.58 -0.0170597 17.0486 0.451569C17.5173 0.920199 17.5173 1.68 17.0486 2.14863L10.6972 8.5001L17.0486 14.8516C17.5173 15.3202 17.5173 16.08 17.0486 16.5486C16.58 17.0173 15.8202 17.0173 15.3516 16.5486L9.0001 10.1972L2.64863 16.5486C2.18 17.0173 1.4202 17.0173 0.951569 16.5486C0.48294 16.08 0.48294 15.3202 0.951569 14.8516L7.30304 8.5001L0.951569 2.14863C0.48294 1.68 0.48294 0.920199 0.951569 0.451569Z" fill="#848484" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col sh-[287px]">
|
||||
<div className="flex flex-col justify-between">
|
||||
<div className="text-lg font-semibold text-black">핸들</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="w-[400px] h-[56px] border-[#d5d5d5] border-1 bg-white text-black font-normal rounded-lg flex items-center justify-center text-md p-1 px-2">
|
||||
<input type="text" className="w-full h-full border-none outline-none" ref={handleInputRef}/>
|
||||
</div>
|
||||
<div className="ml-2 w-[140px] h-[50px] border-[#D73B29] bg-[#F94B37] text-white font-bold rounded-lg flex items-center justify-center text-xl cursor-pointer hover:bg-[#D73B29] transition-colors" onClick={() => get_code()}>
|
||||
코드 발급
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col justify-between mt-5">
|
||||
<div className="text-lg font-semibold text-black">채널 등록 코드</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
|
||||
|
||||
<div className="w-full h-[56px] border-[#d5d5d5] border-1 bg-white text-black font-normal rounded-lg flex items-center justify-start text-xl p-5">
|
||||
{registerCode ? registerCode : "코드를 발급해주세요"}
|
||||
</div>
|
||||
{registerCode && (
|
||||
<div
|
||||
className="w-[45px] h-[56px] border-[#d5d5d5] border-1 bg-white rounded-lg flex items-center justify-center cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(registerCode);
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 9H11C9.89543 9 9 9.89543 9 11V20C9 21.1046 9.89543 22 11 22H20C21.1046 22 22 21.1046 22 20V11C22 9.89543 21.1046 9 20 9Z" stroke="#666666" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5" stroke="#666666" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div className="mt-3 w-[100%-50px] h-[56px] border-[#D73B29] bg-[#F94B37] text-white font-bold rounded-lg flex items-center justify-center text-xl cursor-pointer hover:bg-[#D73B29] transition-colors" onClick={() => register_channel()}>
|
||||
채널 등록
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
app/usr/3_jsmanage/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
'use client'
|
||||
import Modal from "@/app/components/Modal";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function Page() {
|
||||
const [noticeList, setNoticeList] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
is_approved: boolean;
|
||||
createdAt: Date;
|
||||
}[]>([]);
|
||||
const [isreqmodal, setIsreqmodal] = useState(false);
|
||||
|
||||
const [isAdmin, setIsAdmin] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setNoticeList([
|
||||
{
|
||||
id: '1',
|
||||
title: '8월 정산 요청',
|
||||
is_approved: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '7월 정산 요청',
|
||||
is_approved: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '6월 정산 요청',
|
||||
is_approved: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
return (
|
||||
<div className="bg-white h-full">
|
||||
{isAdmin && (
|
||||
<div className="w-full flex flex-row justify-end items-center px-4 max-w-[800px] mx-auto">
|
||||
<div
|
||||
className="w-[86px] h-[36px] border-1 border-[#d5d5d5] bg-[#f94b37] text-white font-semibold rounded-lg flex items-center justify-center my-4 hover:bg-[#d73b29] cursor-pointer"
|
||||
onClick={() => setIsreqmodal(true)}
|
||||
>
|
||||
정산요청
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[calc(100%-80px)] min-w-[500px] max-w-[800px] mx-auto overflow-y-auto p-4 flex flex-col">
|
||||
{noticeList.map((notice) => (
|
||||
<div key={notice.id} className="w-full h-[75px] border-[#d5d5d5] border-1 rounded-lg p-4 my-2 flex flex-row justify-between hover:border-[#F94B37] cursor-pointer">
|
||||
|
||||
<div className="flex flex-col justify-between">
|
||||
<div className="flex flex-row justify-start ">
|
||||
<div className="mr-2 flex items-center justify-center">
|
||||
{!notice.is_approved && ( <div className="w-[40px] h-[20px] bg-[#FEDBD7] text-[#F94B37] font-semibold text-xs rounded-md text-center flex items-center justify-center">미정산</div>
|
||||
)}
|
||||
{notice.is_approved && ( <div className="w-[50px] h-[20px] bg-[#d5d5d5] text-black font-semibold text-xs rounded-md text-center flex items-center justify-center">정산완료</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="text-normal font-semibold flex justify-center items-center truncate overflow-hidden"> {notice.title} </div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[#848484]">
|
||||
{notice.createdAt.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 15L12.5 10L7.5 5" stroke="#848484" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Modal isOpen={isreqmodal} onClose={() => setIsreqmodal(false)}>
|
||||
<div className=" w-full h-full flex flex-col justify-between">
|
||||
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="text-3xl font-semibold">정산요청</div>
|
||||
<div className="text-3xl cursor-pointer" onClick={() => setIsreqmodal(false)}>
|
||||
<svg width="18" height="17" viewBox="0 0 18 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0.951569 0.451569C1.4202 -0.0170597 2.18 -0.0170597 2.64863 0.451569L9.0001 6.80304L15.3516 0.451569C15.8202 -0.0170597 16.58 -0.0170597 17.0486 0.451569C17.5173 0.920199 17.5173 1.68 17.0486 2.14863L10.6972 8.5001L17.0486 14.8516C17.5173 15.3202 17.5173 16.08 17.0486 16.5486C16.58 17.0173 15.8202 17.0173 15.3516 16.5486L9.0001 10.1972L2.64863 16.5486C2.18 17.0173 1.4202 17.0173 0.951569 16.5486C0.48294 16.08 0.48294 15.3202 0.951569 14.8516L7.30304 8.5001L0.951569 2.14863C0.48294 1.68 0.48294 0.920199 0.951569 0.451569Z" fill="#848484" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between h-[287px]">
|
||||
<div className="flex flex-col justify-between">
|
||||
<div className="text-lg font-semibold text-black">총 미정산 수익</div>
|
||||
<div className="w-[428px] h-[56px] border-[#d5d5d5] border-1 bg-white text-black font-bold rounded-lg flex items-center justify-center text-xl">
|
||||
000,000,000원
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between">
|
||||
<div className="text-lg font-semibold text-black">정산 요청 금액</div>
|
||||
<div className="w-[428px] h-[56px] border-[#d5d5d5] border-1 bg-white text-black font-bold rounded-lg flex items-center justify-center text-xl">
|
||||
000,000,000원
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between">
|
||||
<div className="text-lg font-semibold text-black">정산 요청 채널</div>
|
||||
<div className="w-[428px] h-[56px] border-[#d5d5d5] border-1 bg-white text-black font-normal rounded-lg flex items-center justify-starttext-xl p-5">
|
||||
@ 채널명 포함 3개 채널
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="w-[428px] h-[56px] border-[#D73B29] bg-[#F94B37] text-white font-bold rounded-lg flex items-center justify-center text-xl cursor-pointer" onClick={() => setIsreqmodal(false)}>
|
||||
요청 하기
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
141
app/usr/4_noticeboard/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from "react";
|
||||
import NoticeModal from "@/app/components/ModelNotice";
|
||||
import { MilkdownViewer } from "@/app/components/MilkdownViewer";
|
||||
import { MilkdownProvider } from "@milkdown/react";
|
||||
import { Crepe } from "@milkdown/crepe";
|
||||
import { useRef } from "react";
|
||||
|
||||
export default function Page() {
|
||||
const crepeRef = useRef<Crepe>(null!);
|
||||
const [noticeList, setNoticeList] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
pubDate: Date;
|
||||
content: string;
|
||||
tag: string;
|
||||
}[]>([]);
|
||||
|
||||
const [currentNotice, setCurrentNotice] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tag:string
|
||||
pubDate: Date;
|
||||
}>({
|
||||
id: "",
|
||||
tag: "",
|
||||
title: "",
|
||||
content: "",
|
||||
pubDate: new Date("0"),
|
||||
});
|
||||
|
||||
|
||||
const [isNoticeModalOpen, setIsNoticeModalOpen] = useState(false);
|
||||
|
||||
const openHandler = (id: string) => {
|
||||
const notice = noticeList.find((notice) => notice.id === id);
|
||||
if (notice) {
|
||||
setCurrentNotice({
|
||||
id: notice.id,
|
||||
title: notice.title,
|
||||
content: notice.content,
|
||||
tag: notice.tag,
|
||||
pubDate: notice.pubDate,
|
||||
});
|
||||
}
|
||||
setIsNoticeModalOpen(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
const fetchNotices = async () => {
|
||||
const response = await fetch('/api/notice');
|
||||
const data = await response.json();
|
||||
setNoticeList(data);
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotices();
|
||||
}, []);
|
||||
return (
|
||||
<div className="bg-white h-full w-[100vw] lg:w-[calc(100vw-360px)]">
|
||||
|
||||
<div className="h-[calc(100%-80px)] max-w-[800px] mx-auto overflow-y-auto p-4 flex flex-col">
|
||||
{noticeList.length === 0 ? (
|
||||
<div className="text-center text-gray-500">게시글이 없습니다.</div>
|
||||
) : (
|
||||
noticeList.map((notice) => (
|
||||
<div
|
||||
key={notice.id}
|
||||
className="h-[75px] border-[#d5d5d5] border-1 rounded-lg p-4 my-2 flex flex-row justify-between hover:border-[#F94B37] cursor-pointer min-w-[100px] shrink-0 basis-0 grow-0"
|
||||
onClick={() => openHandler(notice.id)}
|
||||
>
|
||||
|
||||
{/* 여기 min-w-0 추가해서 자식을 제한함 부모와 이 위치 관계 다시 확인 */}
|
||||
<div className="flex flex-col justify-between min-w-0">
|
||||
<div className="flex flex-row justify-start">
|
||||
|
||||
{
|
||||
notice.tag === "중요" && (
|
||||
<div className="mr-2 flex items-center justify-center">
|
||||
<div className="w-[33px] h-[20px] bg-[#FEDBD7] text-[#F94B37] font-semibold text-xs rounded-lg text-center flex items-center justify-center">중요</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="text-normal font-semibold truncate">
|
||||
{notice.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[#848484]">
|
||||
{notice.pubDate.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 15L12.5 10L7.5 5" stroke="#848484" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NoticeModal isOpen={isNoticeModalOpen} onClose={() => setIsNoticeModalOpen(false)}>
|
||||
<div className="flex flex-col h-full bg-white rounded-lg p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"
|
||||
onClick={() => setIsNoticeModalOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col border-b border-gray-200 pb-4 mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800">{currentNotice.title}</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{currentNotice.pubDate.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<MilkdownProvider>
|
||||
<MilkdownViewer value={currentNotice.content} editorRef={crepeRef} />
|
||||
</MilkdownProvider>
|
||||
</div>
|
||||
</div>
|
||||
</NoticeModal>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
0
app/usr/5_admin/page.tsx
Normal file
13
app/usr/layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
// app/layout.tsx (Server Component)
|
||||
import { auth } from "@/auth";
|
||||
import LayoutClient from "@/app/components/LayoutClient";
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<LayoutClient session={session}>
|
||||
{children}
|
||||
</LayoutClient>
|
||||
);
|
||||
}
|
||||
8
app/usr/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function InitPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Initialization Page</h1>
|
||||
<p>Welcome to the initialization page. Please follow the instructions to set up your application.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/usr/test/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export default function WhoAmI() {
|
||||
const { data } = useSession();
|
||||
return (
|
||||
<div>
|
||||
{data?.user?.name} / {data?.user?.email}
|
||||
<br />id: {(data?.user as any)?.id}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
auth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// src/auth.ts
|
||||
import NextAuth from "next-auth";
|
||||
import Google from "next-auth/providers/google";
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: [
|
||||
Google({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}),
|
||||
|
||||
],
|
||||
|
||||
// ...callbacks 등등
|
||||
});
|
||||
33601
datas/allview.csv
Normal file
22
datas/ch2.csv
Normal file
@@ -0,0 +1,22 @@
|
||||
Id,Handle
|
||||
rM3ncw0Ptfg,@everfactory-vv
|
||||
crWGJmWxlnw,@everfactory-vv
|
||||
mcLo-NsO-NM,@everfactory-vv
|
||||
1OHjEOzTmv4,@everfactory-vv
|
||||
Zjqg68TZdQ4,@everfactory-vv
|
||||
rmLlvq99dTU,@everfactory-vv
|
||||
UVVZhU5uJIQ,@everfactory-vv
|
||||
Thhh8TYjmqQ,@everfactory-vv
|
||||
sMtkeSDDxB8,@everfactory-vv
|
||||
K1Bm4kk3rQk,@everfactory-vv
|
||||
kbjc0uQ7qbg,@everfactory-vv
|
||||
mQcZCZwqvxQ,@everfactory-vv
|
||||
7PtmMeek6L8,@everfactory-vv
|
||||
2xVTPCwDVgU,@everfactory-vv
|
||||
YEPMCEt1ecc,@everfactory-vv
|
||||
PmkxScRvHe8,@everfactory-vv
|
||||
nhQ9A6XEYDs,@everfactory-vv
|
||||
5o7uOU2DMyU,@everfactory-vv
|
||||
ENu1Z1E_NoY,@everfactory-vv
|
||||
fAAaXBn9O-U,@everfactory-vv
|
||||
zvAVfjijiCk,@everfactory-vv
|
||||
|
68
datas/content_handle.csv
Normal file
@@ -0,0 +1,68 @@
|
||||
Id,Handle
|
||||
nuahXCZWXPo,@한컵레시피
|
||||
lwc_pIdJY8I,@한컵레시피
|
||||
loJ_rxbqrZU,@한컵레시피
|
||||
NnkNOeMfM5c,@한컵레시피
|
||||
wNdv9WcFmQ0,@한컵레시피
|
||||
CWXePp-Xrm4,@한컵레시피
|
||||
RXTrXVRTfv0,@한컵레시피
|
||||
sM0i4naEPLM,@한컵레시피
|
||||
ZG_6QcaH91U,@한컵레시피
|
||||
-dxXNlgUOXM,@한컵레시피
|
||||
6531T9LmSkE,@한컵레시피
|
||||
6KmmHx-Oy6I,@한컵레시피
|
||||
37VUCIY9fLU,@한컵레시피
|
||||
xkYFC--fHN0,@한컵레시피
|
||||
HfcKOEiRZ-o,@한컵레시피
|
||||
B6DoyRp5mmw,@한컵레시피
|
||||
M7gkSmNfxno,@한컵레시피
|
||||
0tRjttSqmso,@한컵레시피
|
||||
UmR8_sqm7s8,@한컵레시피
|
||||
xWCnExggxSQ,@한컵레시피
|
||||
cMiHkXLA_Uo,@한컵레시피
|
||||
goFwS_M7gCU,@한컵레시피
|
||||
ZkopiYZ4axA,@한컵레시피
|
||||
6zVcsa75DBM,@한컵레시피
|
||||
mgTf6dtRWhU,@한컵레시피
|
||||
j21gZFMCbro,@한컵레시피
|
||||
pKzVoZMUjrc,@한컵레시피
|
||||
0yaQSG6H35A,@한컵레시피
|
||||
N7Af6IBj6N8,@한컵레시피
|
||||
2oEHULzBpe4,@한컵레시피
|
||||
ksJf608acRw,@한컵레시피
|
||||
LDIUV4rsowU,@한컵레시피
|
||||
J_ej2T9U3yk,@한컵레시피
|
||||
ZwKoCzm_Y9E,@한컵레시피
|
||||
USUsaLn1QKw,@한컵레시피
|
||||
GRe70V7-vZo,@한컵레시피
|
||||
9FZtZhY3wv4,@한컵레시피
|
||||
ws2CWeClmGE,@한컵레시피
|
||||
2QcNVPEg1Z8,@한컵레시피
|
||||
wGPOtOKO9G4,@한컵레시피
|
||||
MEgFyAwyruw,@한컵레시피
|
||||
FGod3w0byx0,@한컵레시피
|
||||
uv2To4Tpy40,@한컵레시피
|
||||
eFXkacofqtM,@한컵레시피
|
||||
ITeGIOsFSws,@한컵레시피
|
||||
tTivGZggRDk,@한컵레시피
|
||||
rM3ncw0Ptfg,@everfactory-vv
|
||||
crWGJmWxlnw,@everfactory-vv
|
||||
mcLo-NsO-NM,@everfactory-vv
|
||||
1OHjEOzTmv4,@everfactory-vv
|
||||
Zjqg68TZdQ4,@everfactory-vv
|
||||
rmLlvq99dTU,@everfactory-vv
|
||||
UVVZhU5uJIQ,@everfactory-vv
|
||||
Thhh8TYjmqQ,@everfactory-vv
|
||||
sMtkeSDDxB8,@everfactory-vv
|
||||
K1Bm4kk3rQk,@everfactory-vv
|
||||
kbjc0uQ7qbg,@everfactory-vv
|
||||
mQcZCZwqvxQ,@everfactory-vv
|
||||
7PtmMeek6L8,@everfactory-vv
|
||||
2xVTPCwDVgU,@everfactory-vv
|
||||
YEPMCEt1ecc,@everfactory-vv
|
||||
PmkxScRvHe8,@everfactory-vv
|
||||
nhQ9A6XEYDs,@everfactory-vv
|
||||
5o7uOU2DMyU,@everfactory-vv
|
||||
ENu1Z1E_NoY,@everfactory-vv
|
||||
fAAaXBn9O-U,@everfactory-vv
|
||||
zvAVfjijiCk,@everfactory-vv
|
||||
|
68
datas/contents.csv
Normal file
@@ -0,0 +1,68 @@
|
||||
Id,subject,pubDate,views,validViews,premiumViews,watchTime
|
||||
nuahXCZWXPo,컵으로 오레오쿠키케익 만들기 - 한끼_Cup,"Jun 10, 2025",32709,15705,3301,101.6509
|
||||
lwc_pIdJY8I,컵하나로 콘치즈버터 만들기 - 한끼_Cup,"May 13, 2025",37409,15258,6989,88.2174
|
||||
loJ_rxbqrZU,컵으로 엑설런트 크림 브륄레 만들기 - 한끼_Cup,"Jun 1, 2025",28762,14661,8808,89.384
|
||||
NnkNOeMfM5c,컵하나로 소떡소떡 만들기 - 한끼_Cup,"May 15, 2025",28159,13879,2501,72.1229
|
||||
wNdv9WcFmQ0,컵으로 떠먹는 컵피자 만들기 - 한끼_Cup,"May 18, 2025",25318,12658,6877,97.5433
|
||||
CWXePp-Xrm4,컵으로 전남친 토스트 만들기 - 한끼_Cup,"May 17, 2025",24187,11821,6116,65.9049
|
||||
RXTrXVRTfv0,컵으로 오레오쫀득쿠키 만들기 - 한끼_Cup,"Jun 14, 2025",20564,11464,5332,78.9794
|
||||
sM0i4naEPLM,컵으로 연유초콜릿 만들기 - 한끼_Cup,"May 27, 2025",23864,10652,6664,60.2018
|
||||
ZG_6QcaH91U,"단호박 에그슬럿 만들기 -한끼_Cup 밤호박,단호박","Jul 31, 2025",24272,10497,2126,143.3451
|
||||
-dxXNlgUOXM,컵으로 로투스 치즈케익 만들기 - 한끼_Cup,"Jun 12, 2025",13613,6394,3355,41.5752
|
||||
6531T9LmSkE,컵으로 고구마치즈브리또 만들기 - 한끼_Cup,"May 29, 2025",12189,5698,3441,37.9903
|
||||
6KmmHx-Oy6I,컵으로 또띠아 피자 브리또 만들기 - 한끼_Cup,"Jun 5, 2025",10749,5605,4515,32.2854
|
||||
37VUCIY9fLU,더블치즈햄토스트 만들기,"Aug 19, 2025",11421,4947,3486,40.2133
|
||||
xkYFC--fHN0,컵으로 치즈제육 민들기 - 한끼_Cup,"May 25, 2025",10109,4723,3743,31.8623
|
||||
HfcKOEiRZ-o,정숭제햄치즈롤 만들기,"Aug 21, 2025",6045,3909,1350,22.48
|
||||
B6DoyRp5mmw,깐풍만두 만들기 - 한끼_Cup,"Aug 2, 2025",7825,3888,1956,39.6999
|
||||
M7gkSmNfxno,구운두부 강정 만들기 - 한끼_Cup,"Jul 24, 2025",6064,2654,772,33.9777
|
||||
0tRjttSqmso,소세지 또띠아 만들기 -한끼_Cup,"Jul 29, 2025",5443,2574,1580,18.0177
|
||||
UmR8_sqm7s8,딸기모찌 민들기 - 한끼Cup #여름딸기,"Aug 5, 2025",4470,2476,674,20.4254
|
||||
xWCnExggxSQ,만두 그라탕 만들기 - 한끼_Cup #물만두,"Aug 14, 2025",4760,2365,1256,29.0473
|
||||
cMiHkXLA_Uo,컵으로 오징어볶음 만들기 - 한끼_Cup,"May 31, 2025",4853,2287,1602,17.6316
|
||||
goFwS_M7gCU,컵으로 초코제티브라우니 만들기 - 한끼_Cup,"Jun 17, 2025",5432,2125,1131,11.9966
|
||||
ZkopiYZ4axA,컵으로 다이제 에그타르트 만들기 - 한끼_Cup,"Jul 3, 2025",4987,2103,1750,16.5978
|
||||
6zVcsa75DBM,컵으로 마시멜로우 초코타르트 만들기 - 한끼_Cup,"Jun 21, 2025",3964,2094,731,15.9976
|
||||
mgTf6dtRWhU,컵으로 초코칩 쿠키 만들기 - 한끼_Cup,"Jun 19, 2025",4617,1972,1444,11.9979
|
||||
j21gZFMCbro,컵으로 닭갈비 만들기 - 한끼_Cup,"Jun 3, 2025",4096,1648,1651,11.8163
|
||||
pKzVoZMUjrc,컵으로 팽이부추베이컨말이 만들기 - 한끼_Cup,"May 21, 2025",4127,1592,990,12.1174
|
||||
0yaQSG6H35A,새우 멘보샤 만들기 -한끼_Cup #새우요리 #멘보샤,"Aug 12, 2025",3664,1555,1014,15.8445
|
||||
N7Af6IBj6N8,베이컨에그토스트 만들기 -한끼_Cup,"Aug 5, 2025",3585,1522,914,15.7509
|
||||
2oEHULzBpe4,마늘쫑무침 만들기 - 한끼_Cup,"Jul 22, 2025",3114,1410,276,19.9879
|
||||
ksJf608acRw,컵으로 컵 티라미슈 만들기 - 한끼_Cup,"May 24, 2025",2920,1203,729,7.4371
|
||||
LDIUV4rsowU,치킨마요 덮밥 만들기-한끼_Cup,"Jul 26, 2025",2362,755,629,5.9985
|
||||
J_ej2T9U3yk,컵으로 치즈오븐 스파게티 만들기 - 한끼_Cup,"Jul 10, 2025",2211,687,668,4.8089
|
||||
ZwKoCzm_Y9E,컵으로 파스타 치즈떡볶이 만들기 - 한끼_Cup,"Jun 24, 2025",1476,623,358,4.6206
|
||||
USUsaLn1QKw,더덕구이 만들기 Grilled deodeok -한끼_Cup #더덕구이,"Aug 16, 2025",1867,593,622,3.7365
|
||||
GRe70V7-vZo,베이컨치즈토스트 민들기 - 한끼_Cup,"Aug 5, 2025",1426,586,493,3.4593
|
||||
9FZtZhY3wv4,컵으로 다이어트 초코바나나케익 만들기 - 한끼_Cup,"Jun 7, 2025",2052,572,595,3.4588
|
||||
ws2CWeClmGE,허니콤보치킨 만들기 - 한끼_Cup,"Jun 28, 2025",1721,567,487,3.6471
|
||||
2QcNVPEg1Z8,에그마요 베이컨브레드 만들기 - 한끼_Cup,"Jul 8, 2025",1900,566,477,4.2135
|
||||
wGPOtOKO9G4,"식빵치즈핫도그 만들기 - 한끼_Cup #식빵핫도그,#핫도그만들기","Aug 9, 2025",1778,562,481,4.8828
|
||||
MEgFyAwyruw,서울 고속도로에 미확인 괴생명체 출몰,"Aug 20, 2025",1331,538,256,2.7627
|
||||
FGod3w0byx0,컵으로 마파두부 만들기 - 한끼_Cup,"May 20, 2025",1913,475,538,2.8631
|
||||
uv2To4Tpy40,양송이 게살 치즈구이 만들기 - 한끼_Cup,"Jul 5, 2025",1371,370,404,2.3947
|
||||
eFXkacofqtM,컵으로 밤티라미슈 만들기 - 한끼_Cup,"Jun 26, 2025",971,360,269,2.6383
|
||||
ITeGIOsFSws,"불닭팽이버섯치즈쌈 만들기 - 한끼_Cup #팽이버섯,#불닭볶음면","Aug 7, 2025",440,228,81,1.9858
|
||||
tTivGZggRDk,통마늘 닭똥집 파채구이 만들기 - 한끼_Cup,"Jul 1, 2025",470,111,141,0.9671
|
||||
rM3ncw0Ptfg,ZzuB,"Mar 10, 2025",46,46,16,0.2592
|
||||
crWGJmWxlnw,Twinkle water,"Mar 10, 2025",26,26,4,0.1799
|
||||
mcLo-NsO-NM,merry hill,"Apr 28, 2025",21,21,6,0.2222
|
||||
1OHjEOzTmv4,ZzuB,"Mar 10, 2025",20,20,15,0.0852
|
||||
Zjqg68TZdQ4,Raccoon dance,"Mar 10, 2025",19,19,12,0.0768
|
||||
rmLlvq99dTU,2025년 8월 22일,"Aug 22, 2025",68,19,17,0.079
|
||||
UVVZhU5uJIQ,Twinkle water,"Mar 10, 2025",16,16,7,0.1588
|
||||
Thhh8TYjmqQ,Raccoon dance,"Mar 10, 2025",14,14,6,0.0637
|
||||
sMtkeSDDxB8,Smile king,"Jun 22, 2025",12,12,2,0.0717
|
||||
K1Bm4kk3rQk,merry hill,"Apr 29, 2025",11,11,3,0.0336
|
||||
kbjc0uQ7qbg,Lilac love,"Apr 29, 2025",9,9,1,0.0508
|
||||
mQcZCZwqvxQ,select romance,"Apr 29, 2025",9,9,1,0.0413
|
||||
7PtmMeek6L8,The Flight Thief,"Apr 28, 2025",8,8,5,0.1007
|
||||
2xVTPCwDVgU,select romance,"Apr 28, 2025",7,7,4,0.1054
|
||||
YEPMCEt1ecc,Lilac love,"Apr 28, 2025",7,7,5,0.133
|
||||
PmkxScRvHe8,Light Waltz,"Apr 28, 2025",6,6,5,0.1103
|
||||
nhQ9A6XEYDs,light waltz,"Apr 29, 2025",6,6,1,0.0275
|
||||
5o7uOU2DMyU,The Flight Thief,"Apr 29, 2025",5,5,2,0.0181
|
||||
ENu1Z1E_NoY,Sunny afternoon,"Jun 27, 2025",4,4,1,0.0107
|
||||
fAAaXBn9O-U,Sunny afternoon,"Jun 27, 2025",3,3,1,0.0275
|
||||
zvAVfjijiCk,Smile king,"Jun 22, 2025",3,3,0,0.0247
|
||||
|
1826
datas/viewperdate.csv
Normal file
54
middleware.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// src/middleware.ts
|
||||
import { auth } from "@/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
|
||||
|
||||
export default auth(async (req) => {
|
||||
// 세션 가져오기
|
||||
const session = req.auth;
|
||||
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
|
||||
if (pathname.startsWith("/admin")) {
|
||||
const email = session?.user?.email;
|
||||
if (email !== "wsx204@naver.com") {
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/admin")) {
|
||||
const email = session?.user?.email;
|
||||
if (email !== "wsx204@naver.com") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 이미 로그인된 경우 루트 경로로 접근 시 대시보드로 리다이렉트
|
||||
if (session) {
|
||||
if (pathname === "/") {
|
||||
return NextResponse.redirect(new URL("/usr/1_dashboard", req.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 예외: 루트(/), 로그인(/login), api/auth 경로는 리다이렉트 안 함
|
||||
if (
|
||||
pathname === "/" ||
|
||||
pathname.startsWith("/login") ||
|
||||
pathname.startsWith("/api/auth")
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 그 외 로그인 안 된 요청은 / 로 리다이렉트
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
});
|
||||
|
||||
// 모든 경로에 적용
|
||||
export const config = {
|
||||
matcher: ["/((?!_next|.*\\..*).*)"], // _next/static, 이미지, 파일 등 제외
|
||||
};
|
||||
10
next.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
domains: ["lh3.googleusercontent.com"],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
5226
package-lock.json
generated
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "ef_front",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 9551",
|
||||
"build": "next build",
|
||||
"start": "next start -p 9551",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@milkdown/crepe": "^7.15.3",
|
||||
"@milkdown/kit": "^7.15.3",
|
||||
"@milkdown/react": "^7.15.3",
|
||||
"@prisma/client": "^6.13.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"next": "15.4.6",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"react": "19.1.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"papaparse": "^5.5.3",
|
||||
"prisma": "^6.13.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
13
prisma/migrations/20250809040442_init/migration.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `Post` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`subject` VARCHAR(191) NOT NULL,
|
||||
`pubDate` DATETIME(3) NOT NULL,
|
||||
`views` INTEGER NOT NULL,
|
||||
`validViews` INTEGER NOT NULL,
|
||||
`premiumViews` INTEGER NOT NULL,
|
||||
`watchTime` INTEGER NOT NULL,
|
||||
`publisher` VARCHAR(191) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
2
prisma/migrations/20250809040616_init/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Post` MODIFY `publisher` VARCHAR(191) NULL;
|
||||
22
prisma/migrations/20250809040702_init/migration.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Post` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE `Post`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `content` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`subject` VARCHAR(191) NOT NULL,
|
||||
`pubDate` DATETIME(3) NOT NULL,
|
||||
`views` INTEGER NOT NULL,
|
||||
`validViews` INTEGER NOT NULL,
|
||||
`premiumViews` INTEGER NOT NULL,
|
||||
`watchTime` INTEGER NOT NULL,
|
||||
`publisher` VARCHAR(191) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
18
prisma/migrations/20250817093657_mig/migration.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `noticeBoard` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`content` VARCHAR(191) NOT NULL,
|
||||
`pubDate` DATETIME(3) NOT NULL,
|
||||
`isDeleted` BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `noticeBoardTag` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `tag` to the `noticeBoard` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `content` ADD COLUMN `owner` VARCHAR(191) NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `noticeBoard` ADD COLUMN `tag` VARCHAR(191) NOT NULL,
|
||||
MODIFY `content` LONGTEXT NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `registerChannel` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NOT NULL,
|
||||
`handle` VARCHAR(191) NOT NULL,
|
||||
`randomcode` VARCHAR(191) NOT NULL,
|
||||
`createtime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
11
prisma/migrations/20250822203011_/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `userHandle` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NOT NULL,
|
||||
`handle` VARCHAR(191) NOT NULL,
|
||||
`isApproved` BOOLEAN NOT NULL DEFAULT false,
|
||||
`createtime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `userHandle_handle_key`(`handle`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
10
prisma/migrations/20250823131705_handle/migration.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- DropIndex
|
||||
DROP INDEX `userHandle_handle_key` ON `userHandle`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `GlobalValue` (
|
||||
`id` INTEGER NOT NULL DEFAULT 1,
|
||||
`costPerView` DOUBLE NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
2
prisma/migrations/20250823135944_handle/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `userHandle` ADD COLUMN `icon` VARCHAR(191) NOT NULL DEFAULT '';
|
||||
16
prisma/migrations/20250824044021_/migration.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `GlobalValue` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE `GlobalValue`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `costPerView` (
|
||||
`id` INTEGER NOT NULL DEFAULT 1,
|
||||
`costPerView` DOUBLE NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `owner` on the `content` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `publisher` on the `content` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[handle]` on the table `userHandle` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `content` DROP COLUMN `owner`,
|
||||
DROP COLUMN `publisher`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `ContentHandle` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`contentId` VARCHAR(191) NOT NULL,
|
||||
`handle` VARCHAR(191) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `ContentHandle_contentId_key`(`contentId`),
|
||||
INDEX `ContentHandle_handle_idx`(`handle`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `userHandle_handle_key` ON `userHandle`(`handle`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `ContentHandle` ADD CONSTRAINT `ContentHandle_contentId_fkey` FOREIGN KEY (`contentId`) REFERENCES `content`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `ContentHandle` ADD CONSTRAINT `ContentHandle_handle_fkey` FOREIGN KEY (`handle`) REFERENCES `userHandle`(`handle`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `ViewPerDay` (
|
||||
`date` DATETIME(3) NOT NULL,
|
||||
`contented` VARCHAR(191) NOT NULL,
|
||||
`validViewDay` INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (`date`, `contented`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
22
prisma/migrations/20250829194837_001/migration.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `noticeBoardTag` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE `noticeBoardTag`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `contentDayView` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`contentId` VARCHAR(191) NOT NULL,
|
||||
`date` DATETIME(3) NOT NULL,
|
||||
`views` INTEGER NOT NULL,
|
||||
`validViews` INTEGER NOT NULL,
|
||||
`premiumViews` INTEGER NOT NULL,
|
||||
`watchTime` INTEGER NOT NULL,
|
||||
|
||||
UNIQUE INDEX `contentDayView_contentId_date_key`(`contentId`, `date`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
8
prisma/migrations/20250829195118_002/migration.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `ViewPerDay` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE `ViewPerDay`;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
102
prisma/schema.prisma
Normal file
@@ -0,0 +1,102 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../app/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model content{
|
||||
id String @id @default(uuid())
|
||||
subject String
|
||||
pubDate DateTime
|
||||
views Int
|
||||
validViews Int
|
||||
premiumViews Int
|
||||
watchTime Int
|
||||
// Back relation: a content may link to at most one handle
|
||||
contentHandle ContentHandle?
|
||||
}
|
||||
|
||||
model contentDayView{
|
||||
id String @id @default(uuid())
|
||||
contentId String
|
||||
date DateTime
|
||||
views Int
|
||||
validViews Int
|
||||
premiumViews Int
|
||||
watchTime Int
|
||||
@@unique([contentId, date])
|
||||
}
|
||||
|
||||
|
||||
model userHandle {
|
||||
id String @id @default(uuid())
|
||||
email String
|
||||
handle String @unique
|
||||
icon String @default("")
|
||||
isApproved Boolean @default(false)
|
||||
createtime DateTime @default(now())
|
||||
// Relation: a handle can be linked to many contents
|
||||
contentLinks ContentHandle[]
|
||||
}
|
||||
|
||||
model noticeBoard{
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
content String @db.LongText
|
||||
tag String
|
||||
pubDate DateTime
|
||||
isDeleted Boolean @default(false)
|
||||
}
|
||||
|
||||
|
||||
model registerChannel {
|
||||
id String @id @default(uuid())
|
||||
email String
|
||||
handle String
|
||||
randomcode String
|
||||
createtime DateTime @default(now())
|
||||
}
|
||||
|
||||
model costPerView{
|
||||
id Int @id @default(1)
|
||||
costPerView Float
|
||||
}
|
||||
|
||||
|
||||
// Mapping table: Each content can be linked to at most one handle (unique contentId)
|
||||
// A handle can have many contents
|
||||
model ContentHandle {
|
||||
id String @id @default(uuid())
|
||||
contentId String @unique
|
||||
handle String
|
||||
|
||||
// Relations
|
||||
content content @relation(fields: [contentId], references: [id])
|
||||
user userHandle @relation(fields: [handle], references: [handle])
|
||||
|
||||
@@index([handle])
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
npx prisma migrate dev --name <migration_name>
|
||||
새로운 마이그레이션 파일 생성
|
||||
npx prisma generate
|
||||
프리즈마 클라이언트 생성
|
||||
npx prisma db push
|
||||
데이터베이스 동기화
|
||||
npx prisma studio
|
||||
*/
|
||||
|
||||
|
||||
BIN
public/bgmv.mp4
Normal file
BIN
public/ever_logo.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/imsi_bg.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/menuimg/dash.png
Normal file
|
After Width: | Height: | Size: 565 B |
BIN
public/menuimg/js.png
Normal file
|
After Width: | Height: | Size: 511 B |
BIN
public/menuimg/my.png
Normal file
|
After Width: | Height: | Size: 531 B |
BIN
public/menuimg/nt.png
Normal file
|
After Width: | Height: | Size: 336 B |
BIN
public/menuimg/sj.png
Normal file
|
After Width: | Height: | Size: 499 B |
3
public/svg/dashboard.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6758 3.6582C11.9147 3.44753 12.2806 3.44753 12.5195 3.6582L20.3936 10.5996C20.5258 10.7162 20.5976 10.8801 20.5977 11.0479V19.8945C20.5974 20.2155 20.3307 20.5 19.9717 20.5H4.22363C3.86456 20.5 3.59787 20.2155 3.59766 19.8945V11.0479C3.59768 10.8801 3.66953 10.7162 3.80176 10.5996L11.6758 3.6582Z" stroke="#3F2929" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
3
public/svg/everlogo.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="20" viewBox="0 0 32 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 13.7815C0 11.1485 1.5051 9.48685 3.07055 8.61376C4.53165 7.7989 6.17151 7.56314 7.25262 7.56307H29.6288C30.9383 7.56309 32 8.65413 32 10C31.9999 11.3458 30.9383 12.4369 29.6288 12.4369H7.25262C6.70651 12.437 5.90554 12.5782 5.33295 12.8974C5.06963 13.0443 4.93117 13.1859 4.86171 13.2901C4.80809 13.3706 4.74246 13.5037 4.74246 13.7815C4.7425 14.0589 4.80813 14.1913 4.86171 14.2718C4.93111 14.376 5.06944 14.5175 5.33295 14.6644C5.90555 14.9838 6.70643 15.126 7.25262 15.1261H23.5259C24.8355 15.1261 25.8971 16.2172 25.8971 17.5631C25.8969 18.9088 24.8354 20 23.5259 20H7.25262C6.17151 19.9999 4.53165 19.763 3.07055 18.9481C1.50525 18.075 9.38884e-05 16.4143 0 13.7815ZM29.6288 0C30.9383 2.26081e-05 32 1.09107 32 2.43693C32 3.7828 30.9383 4.87385 29.6288 4.87387H14.5759C13.2664 4.87375 12.2046 3.78275 12.2046 2.43693C12.2046 1.09112 13.2664 0.000115545 14.5759 0H29.6288Z" fill="#F94B37"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1010 B |
3
public/svg/jsmanage.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.09766 3.05263C2.09766 2.47128 2.54537 2 3.09766 2H21.0977C21.6499 2 22.0977 2.47128 22.0977 3.05263C22.0977 3.63398 21.6499 4.10526 21.0977 4.10526V15.6842C21.0977 16.2426 20.8869 16.778 20.5119 17.1729C20.1368 17.5677 19.6281 17.7895 19.0977 17.7895H14.5119L16.8048 20.203C17.1953 20.6141 17.1953 21.2806 16.8048 21.6917C16.4142 22.1028 15.7811 22.1028 15.3905 21.6917L12.0977 18.2255L8.80476 21.6917C8.41424 22.1028 7.78107 22.1028 7.39055 21.6917C7.00003 21.2806 7.00003 20.6141 7.39055 20.203L9.68344 17.7895H5.09766C4.56722 17.7895 4.05852 17.5677 3.68344 17.1729C3.30837 16.778 3.09766 16.2426 3.09766 15.6842V4.10526C2.54537 4.10526 2.09766 3.63398 2.09766 3.05263ZM5.09766 4.10526V15.6842H12.097C12.0974 15.6842 12.0979 15.6842 12.0983 15.6842H19.0977V4.10526H5.09766ZM16.0977 6.21053C16.6499 6.21053 17.0977 6.68181 17.0977 7.26316V12.5263C17.0977 13.1077 16.6499 13.5789 16.0977 13.5789C15.5454 13.5789 15.0977 13.1077 15.0977 12.5263V7.26316C15.0977 6.68181 15.5454 6.21053 16.0977 6.21053ZM12.0977 8.31579C12.6499 8.31579 13.0977 8.78707 13.0977 9.36842V12.5263C13.0977 13.1077 12.6499 13.5789 12.0977 13.5789C11.5454 13.5789 11.0977 13.1077 11.0977 12.5263V9.36842C11.0977 8.78707 11.5454 8.31579 12.0977 8.31579ZM8.09766 10.4211C8.64994 10.4211 9.09766 10.8923 9.09766 11.4737V12.5263C9.09766 13.1077 8.64994 13.5789 8.09766 13.5789C7.54537 13.5789 7.09766 13.1077 7.09766 12.5263V11.4737C7.09766 10.8923 7.54537 10.4211 8.09766 10.4211Z" fill="#3F2929"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
3
public/svg/mychannel.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.3419 4.18083C19.2127 4.12797 19.0743 4.10137 18.9347 4.10258C18.7951 4.1038 18.6572 4.1328 18.5289 4.18789C18.4007 4.24299 18.2847 4.32308 18.1877 4.42348L18.1749 4.43652L9.45656 13.1548V14.6411H10.9428L19.6611 5.92279L19.6742 5.90998C19.7746 5.813 19.8547 5.697 19.9098 5.56874C19.9649 5.44049 19.9939 5.30254 19.9951 5.16295C19.9963 5.02337 19.9697 4.88494 19.9168 4.75573C19.864 4.62654 19.7859 4.50916 19.6872 4.41045C19.5885 4.31175 19.4711 4.23369 19.3419 4.18083ZM18.9164 2.00012C19.3352 1.99648 19.7505 2.07628 20.1381 2.23485C20.5257 2.39343 20.8778 2.62761 21.1739 2.92373C21.47 3.21985 21.7042 3.57198 21.8628 3.95957C22.0214 4.34716 22.1012 4.76246 22.0975 5.18122C22.0939 5.59998 22.0069 6.01383 21.8416 6.3986C21.6777 6.78029 21.4399 7.12579 21.1421 7.41528L12.1216 16.4357C11.9245 16.6329 11.6571 16.7436 11.3783 16.7436H8.40529C7.82469 16.7436 7.35402 16.273 7.35402 15.6924V12.7194C7.35402 12.4406 7.46477 12.1732 7.66193 11.976L16.6823 2.95559C16.9718 2.65776 17.3174 2.42001 17.6991 2.25605C18.0838 2.09076 18.4977 2.00376 18.9164 2.00012ZM3.02139 5.05211C3.61284 4.46066 4.41503 4.12838 5.25147 4.12838H10.5078C11.0884 4.12838 11.5591 4.59905 11.5591 5.17965C11.5591 5.76025 11.0884 6.23092 10.5078 6.23092H5.25147C4.97266 6.23092 4.70526 6.34168 4.50811 6.53883C4.31096 6.73599 4.2002 7.00338 4.2002 7.2822V18.8462C4.2002 19.125 4.31096 19.3924 4.50811 19.5895C4.70526 19.7867 4.97266 19.8975 5.25147 19.8975H16.8155C17.0943 19.8975 17.3617 19.7867 17.5588 19.5895C17.756 19.3924 17.8667 19.125 17.8667 18.8462V13.5898C17.8667 13.0092 18.3374 12.5386 18.918 12.5386C19.4986 12.5386 19.9693 13.0092 19.9693 13.5898V18.8462C19.9693 19.6826 19.637 20.4848 19.0455 21.0763C18.4541 21.6677 17.6519 22 16.8155 22H5.25147C4.41503 22 3.61284 21.6677 3.02139 21.0763C2.42993 20.4848 2.09766 19.6826 2.09766 18.8462V7.2822C2.09766 6.44575 2.42993 5.64357 3.02139 5.05211Z" fill="#3F2929"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
3
public/svg/noticeboard.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="17" viewBox="0 0 20 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.446617 1.42007C1.79696 0.522928 3.58036 0 5.5 0C7.16769 0 8.73257 0.394673 10 1.08651C11.2674 0.394673 12.8323 0 14.5 0C16.4207 0 18.2031 0.522959 19.5534 1.42007C19.8323 1.60541 20 1.91809 20 2.253V15.253C20 15.6215 19.7973 15.9602 19.4726 16.1343C19.1478 16.3084 18.7536 16.2899 18.4466 16.0859C17.4609 15.431 16.0733 15 14.5 15C12.9276 15 11.539 15.4311 10.5534 16.0859C10.2181 16.3087 9.78191 16.3087 9.44662 16.0859C8.46096 15.4311 7.07236 15 5.5 15C3.92764 15 2.53904 15.4311 1.55338 16.0859C1.24644 16.2899 0.852214 16.3084 0.527445 16.1343C0.202675 15.9602 0 15.6215 0 15.253V2.253C0 1.91809 0.167658 1.60541 0.446617 1.42007ZM9 2.81949C8.06033 2.31667 6.84766 2 5.5 2C4.15234 2 2.93967 2.31667 2 2.81949V13.6261C3.0538 13.2225 4.24792 13 5.5 13C6.75208 13 7.9462 13.2225 9 13.6261V2.81949ZM11 13.6261C12.0538 13.2225 13.2479 13 14.5 13C15.7527 13 16.9465 13.2224 18 13.626V2.81947C17.0605 2.31664 15.8485 2 14.5 2C13.1523 2 11.9397 2.31667 11 2.81949V13.6261Z" fill="#F94B37"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/uploads/0ffa7af2-fe95-4e37-94f4-9e743f4f01c0.png
Normal file
|
After Width: | Height: | Size: 184 KiB |