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

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