first commit

This commit is contained in:
2025-09-07 22:57:43 +00:00
commit 3bd542adbf
122 changed files with 45056 additions and 0 deletions

43
.gitignore vendored Normal file
View 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
View 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.

View 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
View 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>
);
}

View 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();
}
}

View 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();
}
}

View File

@@ -0,0 +1,2 @@
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers

View 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 });
}
}

View 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);
// }

View 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);
}

View 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 });
}
}

View 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();
}
}

View 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();
}
}

View 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
View 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 });
}

View 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
View 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 });
}

View 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>
</>
);
}

View 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>
</>
)
}

View 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>
);
}

View 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>
);
}

View 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
View 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;

View 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
View 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;

View 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
View 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 />;
};

View 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>
)
}

View 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>
);
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

94
app/fonts/SUIT/LICENSE Normal file
View 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

52
app/fontsuit.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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 등
/>
);
}

View 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
View 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>
);
}

View 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
View File

13
app/usr/layout.tsx Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

22
datas/ch2.csv Normal file
View 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
1 Id Handle
2 rM3ncw0Ptfg @everfactory-vv
3 crWGJmWxlnw @everfactory-vv
4 mcLo-NsO-NM @everfactory-vv
5 1OHjEOzTmv4 @everfactory-vv
6 Zjqg68TZdQ4 @everfactory-vv
7 rmLlvq99dTU @everfactory-vv
8 UVVZhU5uJIQ @everfactory-vv
9 Thhh8TYjmqQ @everfactory-vv
10 sMtkeSDDxB8 @everfactory-vv
11 K1Bm4kk3rQk @everfactory-vv
12 kbjc0uQ7qbg @everfactory-vv
13 mQcZCZwqvxQ @everfactory-vv
14 7PtmMeek6L8 @everfactory-vv
15 2xVTPCwDVgU @everfactory-vv
16 YEPMCEt1ecc @everfactory-vv
17 PmkxScRvHe8 @everfactory-vv
18 nhQ9A6XEYDs @everfactory-vv
19 5o7uOU2DMyU @everfactory-vv
20 ENu1Z1E_NoY @everfactory-vv
21 fAAaXBn9O-U @everfactory-vv
22 zvAVfjijiCk @everfactory-vv

68
datas/content_handle.csv Normal file
View 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
1 Id Handle
2 nuahXCZWXPo @한컵레시피
3 lwc_pIdJY8I @한컵레시피
4 loJ_rxbqrZU @한컵레시피
5 NnkNOeMfM5c @한컵레시피
6 wNdv9WcFmQ0 @한컵레시피
7 CWXePp-Xrm4 @한컵레시피
8 RXTrXVRTfv0 @한컵레시피
9 sM0i4naEPLM @한컵레시피
10 ZG_6QcaH91U @한컵레시피
11 -dxXNlgUOXM @한컵레시피
12 6531T9LmSkE @한컵레시피
13 6KmmHx-Oy6I @한컵레시피
14 37VUCIY9fLU @한컵레시피
15 xkYFC--fHN0 @한컵레시피
16 HfcKOEiRZ-o @한컵레시피
17 B6DoyRp5mmw @한컵레시피
18 M7gkSmNfxno @한컵레시피
19 0tRjttSqmso @한컵레시피
20 UmR8_sqm7s8 @한컵레시피
21 xWCnExggxSQ @한컵레시피
22 cMiHkXLA_Uo @한컵레시피
23 goFwS_M7gCU @한컵레시피
24 ZkopiYZ4axA @한컵레시피
25 6zVcsa75DBM @한컵레시피
26 mgTf6dtRWhU @한컵레시피
27 j21gZFMCbro @한컵레시피
28 pKzVoZMUjrc @한컵레시피
29 0yaQSG6H35A @한컵레시피
30 N7Af6IBj6N8 @한컵레시피
31 2oEHULzBpe4 @한컵레시피
32 ksJf608acRw @한컵레시피
33 LDIUV4rsowU @한컵레시피
34 J_ej2T9U3yk @한컵레시피
35 ZwKoCzm_Y9E @한컵레시피
36 USUsaLn1QKw @한컵레시피
37 GRe70V7-vZo @한컵레시피
38 9FZtZhY3wv4 @한컵레시피
39 ws2CWeClmGE @한컵레시피
40 2QcNVPEg1Z8 @한컵레시피
41 wGPOtOKO9G4 @한컵레시피
42 MEgFyAwyruw @한컵레시피
43 FGod3w0byx0 @한컵레시피
44 uv2To4Tpy40 @한컵레시피
45 eFXkacofqtM @한컵레시피
46 ITeGIOsFSws @한컵레시피
47 tTivGZggRDk @한컵레시피
48 rM3ncw0Ptfg @everfactory-vv
49 crWGJmWxlnw @everfactory-vv
50 mcLo-NsO-NM @everfactory-vv
51 1OHjEOzTmv4 @everfactory-vv
52 Zjqg68TZdQ4 @everfactory-vv
53 rmLlvq99dTU @everfactory-vv
54 UVVZhU5uJIQ @everfactory-vv
55 Thhh8TYjmqQ @everfactory-vv
56 sMtkeSDDxB8 @everfactory-vv
57 K1Bm4kk3rQk @everfactory-vv
58 kbjc0uQ7qbg @everfactory-vv
59 mQcZCZwqvxQ @everfactory-vv
60 7PtmMeek6L8 @everfactory-vv
61 2xVTPCwDVgU @everfactory-vv
62 YEPMCEt1ecc @everfactory-vv
63 PmkxScRvHe8 @everfactory-vv
64 nhQ9A6XEYDs @everfactory-vv
65 5o7uOU2DMyU @everfactory-vv
66 ENu1Z1E_NoY @everfactory-vv
67 fAAaXBn9O-U @everfactory-vv
68 zvAVfjijiCk @everfactory-vv

68
datas/contents.csv Normal file
View 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
1 Id subject pubDate views validViews premiumViews watchTime
2 nuahXCZWXPo 컵으로 오레오쿠키케익 만들기 - 한끼_Cup Jun 10, 2025 32709 15705 3301 101.6509
3 lwc_pIdJY8I 컵하나로 콘치즈버터 만들기 - 한끼_Cup May 13, 2025 37409 15258 6989 88.2174
4 loJ_rxbqrZU 컵으로 엑설런트 크림 브륄레 만들기 - 한끼_Cup Jun 1, 2025 28762 14661 8808 89.384
5 NnkNOeMfM5c 컵하나로 소떡소떡 만들기 - 한끼_Cup May 15, 2025 28159 13879 2501 72.1229
6 wNdv9WcFmQ0 컵으로 떠먹는 컵피자 만들기 - 한끼_Cup May 18, 2025 25318 12658 6877 97.5433
7 CWXePp-Xrm4 컵으로 전남친 토스트 만들기 - 한끼_Cup May 17, 2025 24187 11821 6116 65.9049
8 RXTrXVRTfv0 컵으로 오레오쫀득쿠키 만들기 - 한끼_Cup Jun 14, 2025 20564 11464 5332 78.9794
9 sM0i4naEPLM 컵으로 연유초콜릿 만들기 - 한끼_Cup May 27, 2025 23864 10652 6664 60.2018
10 ZG_6QcaH91U 단호박 에그슬럿 만들기 -한끼_Cup 밤호박,단호박 Jul 31, 2025 24272 10497 2126 143.3451
11 -dxXNlgUOXM 컵으로 로투스 치즈케익 만들기 - 한끼_Cup Jun 12, 2025 13613 6394 3355 41.5752
12 6531T9LmSkE 컵으로 고구마치즈브리또 만들기 - 한끼_Cup May 29, 2025 12189 5698 3441 37.9903
13 6KmmHx-Oy6I 컵으로 또띠아 피자 브리또 만들기 - 한끼_Cup Jun 5, 2025 10749 5605 4515 32.2854
14 37VUCIY9fLU 더블치즈햄토스트 만들기 Aug 19, 2025 11421 4947 3486 40.2133
15 xkYFC--fHN0 컵으로 치즈제육 민들기 - 한끼_Cup May 25, 2025 10109 4723 3743 31.8623
16 HfcKOEiRZ-o 정숭제햄치즈롤 만들기 Aug 21, 2025 6045 3909 1350 22.48
17 B6DoyRp5mmw 깐풍만두 만들기 - 한끼_Cup Aug 2, 2025 7825 3888 1956 39.6999
18 M7gkSmNfxno 구운두부 강정 만들기 - 한끼_Cup Jul 24, 2025 6064 2654 772 33.9777
19 0tRjttSqmso 소세지 또띠아 만들기 -한끼_Cup Jul 29, 2025 5443 2574 1580 18.0177
20 UmR8_sqm7s8 딸기모찌 민들기 - 한끼Cup #여름딸기 Aug 5, 2025 4470 2476 674 20.4254
21 xWCnExggxSQ 만두 그라탕 만들기 - 한끼_Cup #물만두 Aug 14, 2025 4760 2365 1256 29.0473
22 cMiHkXLA_Uo 컵으로 오징어볶음 만들기 - 한끼_Cup May 31, 2025 4853 2287 1602 17.6316
23 goFwS_M7gCU 컵으로 초코제티브라우니 만들기 - 한끼_Cup Jun 17, 2025 5432 2125 1131 11.9966
24 ZkopiYZ4axA 컵으로 다이제 에그타르트 만들기 - 한끼_Cup Jul 3, 2025 4987 2103 1750 16.5978
25 6zVcsa75DBM 컵으로 마시멜로우 초코타르트 만들기 - 한끼_Cup Jun 21, 2025 3964 2094 731 15.9976
26 mgTf6dtRWhU 컵으로 초코칩 쿠키 만들기 - 한끼_Cup Jun 19, 2025 4617 1972 1444 11.9979
27 j21gZFMCbro 컵으로 닭갈비 만들기 - 한끼_Cup Jun 3, 2025 4096 1648 1651 11.8163
28 pKzVoZMUjrc 컵으로 팽이부추베이컨말이 만들기 - 한끼_Cup May 21, 2025 4127 1592 990 12.1174
29 0yaQSG6H35A 새우 멘보샤 만들기 -한끼_Cup #새우요리 #멘보샤 Aug 12, 2025 3664 1555 1014 15.8445
30 N7Af6IBj6N8 베이컨에그토스트 만들기 -한끼_Cup Aug 5, 2025 3585 1522 914 15.7509
31 2oEHULzBpe4 마늘쫑무침 만들기 - 한끼_Cup Jul 22, 2025 3114 1410 276 19.9879
32 ksJf608acRw 컵으로 컵 티라미슈 만들기 - 한끼_Cup May 24, 2025 2920 1203 729 7.4371
33 LDIUV4rsowU 치킨마요 덮밥 만들기-한끼_Cup Jul 26, 2025 2362 755 629 5.9985
34 J_ej2T9U3yk 컵으로 치즈오븐 스파게티 만들기 - 한끼_Cup Jul 10, 2025 2211 687 668 4.8089
35 ZwKoCzm_Y9E 컵으로 파스타 치즈떡볶이 만들기 - 한끼_Cup Jun 24, 2025 1476 623 358 4.6206
36 USUsaLn1QKw 더덕구이 만들기 Grilled deodeok -한끼_Cup #더덕구이 Aug 16, 2025 1867 593 622 3.7365
37 GRe70V7-vZo 베이컨치즈토스트 민들기 - 한끼_Cup Aug 5, 2025 1426 586 493 3.4593
38 9FZtZhY3wv4 컵으로 다이어트 초코바나나케익 만들기 - 한끼_Cup Jun 7, 2025 2052 572 595 3.4588
39 ws2CWeClmGE 허니콤보치킨 만들기 - 한끼_Cup Jun 28, 2025 1721 567 487 3.6471
40 2QcNVPEg1Z8 에그마요 베이컨브레드 만들기 - 한끼_Cup Jul 8, 2025 1900 566 477 4.2135
41 wGPOtOKO9G4 식빵치즈핫도그 만들기 - 한끼_Cup #식빵핫도그,#핫도그만들기 Aug 9, 2025 1778 562 481 4.8828
42 MEgFyAwyruw 서울 고속도로에 미확인 괴생명체 출몰 Aug 20, 2025 1331 538 256 2.7627
43 FGod3w0byx0 컵으로 마파두부 만들기 - 한끼_Cup May 20, 2025 1913 475 538 2.8631
44 uv2To4Tpy40 양송이 게살 치즈구이 만들기 - 한끼_Cup Jul 5, 2025 1371 370 404 2.3947
45 eFXkacofqtM 컵으로 밤티라미슈 만들기 - 한끼_Cup Jun 26, 2025 971 360 269 2.6383
46 ITeGIOsFSws 불닭팽이버섯치즈쌈 만들기 - 한끼_Cup #팽이버섯,#불닭볶음면 Aug 7, 2025 440 228 81 1.9858
47 tTivGZggRDk 통마늘 닭똥집 파채구이 만들기 - 한끼_Cup Jul 1, 2025 470 111 141 0.9671
48 rM3ncw0Ptfg ZzuB Mar 10, 2025 46 46 16 0.2592
49 crWGJmWxlnw Twinkle water Mar 10, 2025 26 26 4 0.1799
50 mcLo-NsO-NM merry hill Apr 28, 2025 21 21 6 0.2222
51 1OHjEOzTmv4 ZzuB Mar 10, 2025 20 20 15 0.0852
52 Zjqg68TZdQ4 Raccoon dance Mar 10, 2025 19 19 12 0.0768
53 rmLlvq99dTU 2025년 8월 22일 Aug 22, 2025 68 19 17 0.079
54 UVVZhU5uJIQ Twinkle water Mar 10, 2025 16 16 7 0.1588
55 Thhh8TYjmqQ Raccoon dance Mar 10, 2025 14 14 6 0.0637
56 sMtkeSDDxB8 Smile king Jun 22, 2025 12 12 2 0.0717
57 K1Bm4kk3rQk merry hill Apr 29, 2025 11 11 3 0.0336
58 kbjc0uQ7qbg Lilac love Apr 29, 2025 9 9 1 0.0508
59 mQcZCZwqvxQ select romance Apr 29, 2025 9 9 1 0.0413
60 7PtmMeek6L8 The Flight Thief Apr 28, 2025 8 8 5 0.1007
61 2xVTPCwDVgU select romance Apr 28, 2025 7 7 4 0.1054
62 YEPMCEt1ecc Lilac love Apr 28, 2025 7 7 5 0.133
63 PmkxScRvHe8 Light Waltz Apr 28, 2025 6 6 5 0.1103
64 nhQ9A6XEYDs light waltz Apr 29, 2025 6 6 1 0.0275
65 5o7uOU2DMyU The Flight Thief Apr 29, 2025 5 5 2 0.0181
66 ENu1Z1E_NoY Sunny afternoon Jun 27, 2025 4 4 1 0.0107
67 fAAaXBn9O-U Sunny afternoon Jun 27, 2025 3 3 1 0.0275
68 zvAVfjijiCk Smile king Jun 22, 2025 3 3 0 0.0247

1826
datas/viewperdate.csv Normal file

File diff suppressed because it is too large Load Diff

4
memo.txt Normal file
View File

@@ -0,0 +1,4 @@
대쉬보드 레이아웃
데이터베이스
로그인
파싱

54
middleware.ts Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View 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
View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View 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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Post` MODIFY `publisher` VARCHAR(191) NULL;

View 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;

View 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;

View File

@@ -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;

View 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;

View 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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `userHandle` ADD COLUMN `icon` VARCHAR(191) NOT NULL DEFAULT '';

View 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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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`;

View 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
View 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

Binary file not shown.

BIN
public/ever_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/imsi_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
public/menuimg/dash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

BIN
public/menuimg/js.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 B

BIN
public/menuimg/my.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

BIN
public/menuimg/nt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

BIN
public/menuimg/sj.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 B

3
public/svg/dashboard.svg Normal file
View 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
View 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
View 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
View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Some files were not shown because too many files have changed in this diff Show More