Compare commits
5 Commits
76db85a22f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d21b788652 | |||
| b5802bc398 | |||
| 9c0aeb73e8 | |||
| 0ab4b037eb | |||
| ddb70018b9 |
@@ -28,11 +28,17 @@ function toInt(v: any): number {
|
|||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const urlObj = new URL(request.url)
|
||||||
|
const hostHeader = (request.headers.get('host') || '').toLowerCase()
|
||||||
|
const isLocal = hostHeader.includes('localhost') || hostHeader.includes('127.0.0.1') || urlObj.hostname === 'localhost' || urlObj.hostname === '127.0.0.1'
|
||||||
|
|
||||||
|
if (!isLocal) {
|
||||||
// 세션 사용자 확인 (핸들 매핑용)
|
// 세션 사용자 확인 (핸들 매핑용)
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const dateParam = searchParams.get('date') || 'latest'
|
const dateParam = searchParams.get('date') || 'latest'
|
||||||
@@ -90,6 +96,10 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const isoDate = parseKoreanDateToISO(dateStr) || parseYYMMDD(dateStr) || new Date().toISOString();
|
const isoDate = parseKoreanDateToISO(dateStr) || parseYYMMDD(dateStr) || new Date().toISOString();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
// 날짜를 하루 단위(UTC 자정)로 고정해 동일 날짜는 같은 값으로 취급
|
||||||
|
const dayStart = new Date(isoDate);
|
||||||
|
dayStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
const nextDay = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||||
try {
|
try {
|
||||||
// handle 연결은 비워둠 (요청에 따라 핸들 매핑 생략)
|
// handle 연결은 비워둠 (요청에 따라 핸들 매핑 생략)
|
||||||
|
|
||||||
@@ -123,19 +133,31 @@ export async function GET(request: Request) {
|
|||||||
const premiumViews = toInt(row['YouTube Premium 조회수']);
|
const premiumViews = toInt(row['YouTube Premium 조회수']);
|
||||||
const watchTime = toInt(row['시청 시간(단위: 시간)']);
|
const watchTime = toInt(row['시청 시간(단위: 시간)']);
|
||||||
|
|
||||||
// contentId + date 복합 유니크로 upsert
|
// 같은 날짜(하루 단위)에 이미 존재하면 update, 없으면 create
|
||||||
await prisma.contentDayView.upsert({
|
const existing = await prisma.contentDayView.findFirst({
|
||||||
where: { contentId_date: { contentId: upsertedContent.id, date: new Date(isoDate) } },
|
where: {
|
||||||
update: { views, validViews, premiumViews, watchTime },
|
|
||||||
create: {
|
|
||||||
contentId: upsertedContent.id,
|
contentId: upsertedContent.id,
|
||||||
date: new Date(isoDate),
|
date: { gte: dayStart, lt: nextDay },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await prisma.contentDayView.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { views, validViews, premiumViews, watchTime },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.contentDayView.create({
|
||||||
|
data: {
|
||||||
|
contentId: upsertedContent.id,
|
||||||
|
date: dayStart,
|
||||||
views,
|
views,
|
||||||
validViews,
|
validViews,
|
||||||
premiumViews,
|
premiumViews,
|
||||||
watchTime,
|
watchTime,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
upserted += 1;
|
upserted += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ import Arrow from "./svgs/arrow";
|
|||||||
import { DateRangeEnum } from "@/app/constants/dateRange";
|
import { DateRangeEnum } from "@/app/constants/dateRange";
|
||||||
import { useState,useEffect } from "react";
|
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} ) {
|
export default function CalenderSelector({ dateString, is_small, onRangeChange, isOpen: controlledOpen, onOpenChange }: { dateString: string, is_ri: boolean, is_small: boolean, onRangeChange?: (start: Date, end: Date) => void, isOpen?: boolean, onOpenChange?: (open: boolean) => void }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const CUSTOM = -1; // 커스텀(년/월) 선택 표기용
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const isOpen = typeof controlledOpen === 'boolean' ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = (next: boolean) => { if (onOpenChange) onOpenChange(next); else setInternalOpen(next); };
|
||||||
const [selected, setSelected] = useState<number>(DateRangeEnum.ONE_MONTH);
|
const [selected, setSelected] = useState<number>(DateRangeEnum.ONE_MONTH);
|
||||||
const [rangeStart, setRangeStart] = useState<Date>(new Date());
|
const [rangeStart, setRangeStart] = useState<Date>(new Date());
|
||||||
const [rangeEnd, setRangeEnd] = useState<Date>(new Date());
|
const [rangeEnd, setRangeEnd] = useState<Date>(new Date());
|
||||||
|
const [yearInput, setYearInput] = useState<string>('');
|
||||||
|
const [monthInput, setMonthInput] = useState<string>('');
|
||||||
|
const [customLabel, setCustomLabel] = useState<string>('');
|
||||||
|
const [isCustomActive, setIsCustomActive] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
@@ -16,6 +23,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
setRangeStart(startDate);
|
setRangeStart(startDate);
|
||||||
setRangeEnd(endDate);
|
setRangeEnd(endDate);
|
||||||
onRangeChange?.(startDate, endDate);
|
onRangeChange?.(startDate, endDate);
|
||||||
|
const now = new Date();
|
||||||
|
setYearInput(String(now.getFullYear()));
|
||||||
|
setMonthInput(String(now.getMonth() + 1).padStart(2,'0'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +37,7 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
border-1 rounded-lg flex flex-row items-center justify-between gap-2 px-2 cursor-pointer`}
|
border-1 rounded-lg flex flex-row items-center justify-between gap-2 px-2 cursor-pointer`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('click');
|
console.log('click');
|
||||||
setIsOpen(!isOpen);
|
setOpen(!isOpen);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
@@ -38,13 +48,15 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex-1 min-w-0 font-semibold ${is_small ? 'text-xs':'text-sm'}`}>
|
<div className={`flex-1 min-w-0 font-semibold ${is_small ? 'text-xs':'text-sm'}`}>
|
||||||
{selected === DateRangeEnum.ONE_MONTH&& '최근 1개월'}
|
{customLabel || (
|
||||||
{selected === DateRangeEnum.ONE_WEEK&& '최근 1주일'}
|
selected === DateRangeEnum.ONE_MONTH ? '최근 1개월' :
|
||||||
{selected === DateRangeEnum.TWO_MONTHS&& '최근 2개월'}
|
selected === DateRangeEnum.ONE_WEEK ? '최근 1주일' :
|
||||||
{selected === DateRangeEnum.THREE_MONTHS&& '최근 3개월'}
|
selected === DateRangeEnum.TWO_MONTHS ? '최근 2개월' :
|
||||||
{selected === DateRangeEnum.SIX_MONTHS&& '최근 6개월'}
|
selected === DateRangeEnum.THREE_MONTHS ? '최근 3개월' :
|
||||||
{selected === DateRangeEnum.ONE_YEAR&& '최근 1년'}
|
selected === DateRangeEnum.SIX_MONTHS ? '최근 6개월' :
|
||||||
{selected === DateRangeEnum.ALL&& '전체'}
|
selected === DateRangeEnum.ONE_YEAR ? '최근 1년' :
|
||||||
|
selected === DateRangeEnum.ALL ? '전체' : ''
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`pt-[3px] transition-transform ${isOpen ? 'rotate-180' : ''} flex-shrink-0`}>
|
<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} />
|
<Arrow color="#A4A0A0" width={is_small ? 12 : 18} height={is_small ? 8 : 12} />
|
||||||
@@ -59,11 +71,13 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected(DateRangeEnum.ALL);
|
setSelected(DateRangeEnum.ALL);
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
const start = new Date(0);
|
const start = new Date(Date.UTC(2025, 0, 1));
|
||||||
setRangeStart(start);
|
setRangeStart(start);
|
||||||
setRangeEnd(end);
|
setRangeEnd(end);
|
||||||
onRangeChange?.(start, end);
|
onRangeChange?.(start, end);
|
||||||
setIsOpen(false);
|
setCustomLabel('');
|
||||||
|
setIsCustomActive(false);
|
||||||
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Realtime color={selected === DateRangeEnum.ALL ? '#F94B37' : '#848484'} width={16} height={16} />
|
<Realtime color={selected === DateRangeEnum.ALL ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||||
@@ -79,7 +93,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
setRangeStart(start);
|
setRangeStart(start);
|
||||||
setRangeEnd(end);
|
setRangeEnd(end);
|
||||||
onRangeChange?.(start, end);
|
onRangeChange?.(start, end);
|
||||||
setIsOpen(false);
|
setCustomLabel('');
|
||||||
|
setIsCustomActive(false);
|
||||||
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OneMonth color={selected === DateRangeEnum.ONE_WEEK ? '#F94B37' : '#848484'} width={16} height={16} />
|
<OneMonth color={selected === DateRangeEnum.ONE_WEEK ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||||
@@ -95,7 +111,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
setRangeStart(start);
|
setRangeStart(start);
|
||||||
setRangeEnd(end);
|
setRangeEnd(end);
|
||||||
onRangeChange?.(start, end);
|
onRangeChange?.(start, end);
|
||||||
setIsOpen(false);
|
setCustomLabel('');
|
||||||
|
setIsCustomActive(false);
|
||||||
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OneMonth color={selected === DateRangeEnum.ONE_MONTH ? '#F94B37' : '#848484'} width={16} height={16} />
|
<OneMonth color={selected === DateRangeEnum.ONE_MONTH ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||||
@@ -111,7 +129,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
setRangeStart(start);
|
setRangeStart(start);
|
||||||
setRangeEnd(end);
|
setRangeEnd(end);
|
||||||
onRangeChange?.(start, end);
|
onRangeChange?.(start, end);
|
||||||
setIsOpen(false);
|
setCustomLabel('');
|
||||||
|
setIsCustomActive(false);
|
||||||
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OneMonth color={selected === DateRangeEnum.TWO_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} />
|
<OneMonth color={selected === DateRangeEnum.TWO_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||||
@@ -127,7 +147,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
setRangeStart(start);
|
setRangeStart(start);
|
||||||
setRangeEnd(end);
|
setRangeEnd(end);
|
||||||
onRangeChange?.(start, end);
|
onRangeChange?.(start, end);
|
||||||
setIsOpen(false);
|
setCustomLabel('');
|
||||||
|
setIsCustomActive(false);
|
||||||
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OneMonth color={selected === DateRangeEnum.THREE_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} />
|
<OneMonth color={selected === DateRangeEnum.THREE_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||||
@@ -143,7 +165,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
setRangeStart(start);
|
setRangeStart(start);
|
||||||
setRangeEnd(end);
|
setRangeEnd(end);
|
||||||
onRangeChange?.(start, end);
|
onRangeChange?.(start, end);
|
||||||
setIsOpen(false);
|
setCustomLabel('');
|
||||||
|
setIsCustomActive(false);
|
||||||
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OneMonth color={selected === DateRangeEnum.SIX_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} />
|
<OneMonth color={selected === DateRangeEnum.SIX_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||||
@@ -159,7 +183,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
setRangeStart(start);
|
setRangeStart(start);
|
||||||
setRangeEnd(end);
|
setRangeEnd(end);
|
||||||
onRangeChange?.(start, end);
|
onRangeChange?.(start, end);
|
||||||
setIsOpen(false);
|
setCustomLabel('');
|
||||||
|
setIsCustomActive(false);
|
||||||
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OneMonth color={selected === DateRangeEnum.ONE_YEAR ? '#F94B37' : '#848484'} width={16} height={16} />
|
<OneMonth color={selected === DateRangeEnum.ONE_YEAR ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||||
@@ -167,6 +193,52 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 pt-2 border-t-1 border-border-pale">
|
||||||
|
<div className={`flex items-center gap-2 px-1 ${isCustomActive ? 'bg-[#FFF3F2] font-semibold rounded-md py-2' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-[88px] h-[32px] border-1 border-border-pale rounded px-2 pr-1 text-sm"
|
||||||
|
placeholder="YYYY"
|
||||||
|
min={2000}
|
||||||
|
max={2100}
|
||||||
|
value={yearInput}
|
||||||
|
onChange={(e)=> setYearInput(e.target.value.replace(/[^0-9]/g,''))}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">년</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-[50px] h-[32px] border-1 border-border-pale rounded px-2 pr-1 text-sm"
|
||||||
|
placeholder="MM"
|
||||||
|
min={1}
|
||||||
|
max={12}
|
||||||
|
value={monthInput}
|
||||||
|
onChange={(e)=> {
|
||||||
|
const v = e.target.value.replace(/[^0-9]/g,'');
|
||||||
|
setMonthInput(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">월</span>
|
||||||
|
<button
|
||||||
|
className="ml-auto h-[32px] min-w-[80px] px-4 shrink-0 whitespace-nowrap rounded-md bg-[#F94B37] border-1 border-[#D73B29] text-white text-sm font-semibold hover:bg-[#D73B29]"
|
||||||
|
onClick={() => {
|
||||||
|
const y = parseInt(yearInput, 10);
|
||||||
|
const m = parseInt(monthInput, 10);
|
||||||
|
if (!Number.isFinite(y) || y < 2000 || y > 2100) return;
|
||||||
|
if (!Number.isFinite(m) || m < 1 || m > 12) return;
|
||||||
|
const start = new Date(y, m - 1, 1);
|
||||||
|
const end = new Date(y, m, 0, 23, 59, 59, 999);
|
||||||
|
setRangeStart(start);
|
||||||
|
setRangeEnd(end);
|
||||||
|
onRangeChange?.(start, end);
|
||||||
|
const mm = String(m).padStart(2, '0');
|
||||||
|
setCustomLabel(`${y}.${mm}`);
|
||||||
|
setIsCustomActive(true);
|
||||||
|
setSelected(CUSTOM);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>조회</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ export default function ChanalFilter({
|
|||||||
channel_list,
|
channel_list,
|
||||||
visibleSet,
|
visibleSet,
|
||||||
onChangeVisible,
|
onChangeVisible,
|
||||||
|
isOpen: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
}:{
|
}:{
|
||||||
channel_list: Channel[];
|
channel_list: Channel[];
|
||||||
visibleSet: Set<string>;
|
visibleSet: Set<string>;
|
||||||
onChangeVisible: (nextVisibleIds: string[]) => void;
|
onChangeVisible: (nextVisibleIds: string[]) => void;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const isOpen = typeof controlledOpen === 'boolean' ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = (next: boolean) => { if (onOpenChange) onOpenChange(next); else setInternalOpen(next); };
|
||||||
const allIds = useMemo(() => channel_list.map(c => c.id), [channel_list]);
|
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 allChecked = useMemo(() => allIds.every(id => visibleSet.has(id)) && allIds.length > 0, [allIds, visibleSet]);
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ export default function ChanalFilter({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className={`w-[36px] h-[36px] border-1 rounded-lg flex items-center justify-center cursor-pointer
|
<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 '} `}
|
${isOpen ? 'border-[#F94B37] bg-[#FFF3F2]' : 'border-border-pale bg-white '} `}
|
||||||
onClick={()=>{setIsOpen(!isOpen)}}
|
onClick={()=>{setOpen(!isOpen)}}
|
||||||
>
|
>
|
||||||
<SvgChannelFilter color={isOpen ? '#F94B37' : '#848484'} width={20} height={20} />
|
<SvgChannelFilter color={isOpen ? '#F94B37' : '#848484'} width={20} height={20} />
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +89,7 @@ export default function ChanalFilter({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="mt-2 w-full h-[36px] rounded-md bg-[#F94B37] border-1 border-[#D73B29] text-white font-semibold hover:bg-[#D73B29]"
|
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)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
확인
|
확인
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ export default function Page({user}: {user: any}) {
|
|||||||
const [dateString, setDateString] = useState<string>("2025.03.01 ~ 2025.03.31");
|
const [dateString, setDateString] = useState<string>("2025.03.01 ~ 2025.03.31");
|
||||||
const chartRef = useRef<HTMLDivElement>(null);
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
const [hasRun, setHasRun] = useState(false);
|
const [hasRun, setHasRun] = useState(false);
|
||||||
|
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
|
||||||
// 전역 상수는 공용 파일에서 import
|
// 전역 상수는 공용 파일에서 import
|
||||||
|
|
||||||
@@ -316,9 +318,16 @@ export default function Page({user}: {user: any}) {
|
|||||||
expectedRevenue: { border: '#16A34A', bg: 'rgba(22, 163, 74, 0.25)' },
|
expectedRevenue: { border: '#16A34A', bg: 'rgba(22, 163, 74, 0.25)' },
|
||||||
}
|
}
|
||||||
const selectedRows = useMemo(() => sortedVisibleRows.filter(r => checkedIds.has(r.id)), [sortedVisibleRows, checkedIds])
|
const selectedRows = useMemo(() => sortedVisibleRows.filter(r => checkedIds.has(r.id)), [sortedVisibleRows, checkedIds])
|
||||||
const [seriesMode, setSeriesMode] = useState<'items'|'series'>('items')
|
const selectedIdsKey = useMemo(() => {
|
||||||
|
const ids = Array.from(checkedIds)
|
||||||
|
if (ids.length === 0) return ''
|
||||||
|
ids.sort()
|
||||||
|
return ids.join(',')
|
||||||
|
}, [checkedIds])
|
||||||
|
const [seriesMode, setSeriesMode] = useState<'items'|'series'>('series')
|
||||||
const [seriesInterval, setSeriesInterval] = useState<'day'|'week'|'month'>('day')
|
const [seriesInterval, setSeriesInterval] = useState<'day'|'week'|'month'>('day')
|
||||||
const [seriesItems, setSeriesItems] = useState<Array<{ period: string, views: number, validViews: number, premiumViews: number, expectedRevenue: number }>>([])
|
const [seriesItems, setSeriesItems] = useState<Array<{ period: string, views: number, validViews: number, premiumViews: number, expectedRevenue: number }>>([])
|
||||||
|
const [seriesLoading, setSeriesLoading] = useState<boolean>(false)
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
const col = metricColor[selectedMetric]
|
const col = metricColor[selectedMetric]
|
||||||
if (seriesMode === 'series') {
|
if (seriesMode === 'series') {
|
||||||
@@ -337,6 +346,37 @@ export default function Page({user}: {user: any}) {
|
|||||||
elements: { line: { tension: 0.35 } },
|
elements: { line: { tension: 0.35 } },
|
||||||
}), [])
|
}), [])
|
||||||
|
|
||||||
|
// 시리즈 모드일 때 옵션/기간 변경 시 자동 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (seriesMode !== 'series') return;
|
||||||
|
const s = startDate; const e = endDate;
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('start', s.toISOString());
|
||||||
|
qs.set('end', e.toISOString());
|
||||||
|
qs.set('mode', 'series');
|
||||||
|
qs.set('interval', seriesInterval);
|
||||||
|
if (selectedIdsKey) qs.set('contentIds', selectedIdsKey);
|
||||||
|
let cancelled = false;
|
||||||
|
setSeriesLoading(true);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/contents/mycontent?${qs.toString()}`, { cache: 'no-store' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && !cancelled) setSeriesItems(data.items || []);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setSeriesLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [seriesMode, seriesInterval, startDate, endDate, selectedIdsKey]);
|
||||||
|
|
||||||
|
// 시리즈 모드 해제 시 스피너 숨김 보장
|
||||||
|
useEffect(() => {
|
||||||
|
if (seriesMode !== 'series') setSeriesLoading(false);
|
||||||
|
}, [seriesMode]);
|
||||||
|
|
||||||
// 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제하되
|
// 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제하되
|
||||||
// 초기 로딩 등으로 가시 채널이 비어있을 땐 해제하지 않음.
|
// 초기 로딩 등으로 가시 채널이 비어있을 땐 해제하지 않음.
|
||||||
// 가시 채널이 채워지고 선택이 비어있으면 전체 선택으로 초기화.
|
// 가시 채널이 채워지고 선택이 비어있으면 전체 선택으로 초기화.
|
||||||
@@ -359,12 +399,14 @@ export default function Page({user}: {user: any}) {
|
|||||||
sm:grid-rows-[36px_minmax(400px,600px)_minmax(200px,250px)_minmax(200px,250px)]
|
sm:grid-rows-[36px_minmax(400px,600px)_minmax(200px,250px)_minmax(200px,250px)]
|
||||||
gap-2
|
gap-2
|
||||||
w-full
|
w-full
|
||||||
h-full
|
h-[100dvh]
|
||||||
lg:gap-5
|
lg:gap-5
|
||||||
py-2
|
py-2
|
||||||
">
|
"
|
||||||
|
style={{ paddingBottom: 'max(env(safe-area-inset-bottom), 84px)' }}
|
||||||
|
>
|
||||||
<div className="order-1 col-[1/5] row-[1/2] flex flex-row">
|
<div className="order-1 col-[1/5] row-[1/2] flex flex-row">
|
||||||
<div className="grow flex flex-row items-center overflow-y-hidden overflow-x-auto mx-1 scrollbar-thin-x scrollbar-outside-x">
|
<div className="grow h-[48px] flex flex-row items-center overflow-y-hidden overflow-x-auto mx-1 scrollbar-thin-x scrollbar-outside-x" style={{ scrollbarGutter: 'stable both-edges' }}>
|
||||||
{myChannelList.length === 0 ? (
|
{myChannelList.length === 0 ? (
|
||||||
<div className=" h-[32px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold text-[#848484]">
|
<div className=" h-[32px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold text-[#848484]">
|
||||||
등록된 채널이 없습니다.
|
등록된 채널이 없습니다.
|
||||||
@@ -394,6 +436,8 @@ export default function Page({user}: {user: any}) {
|
|||||||
dateString={dateString}
|
dateString={dateString}
|
||||||
is_ri={true}
|
is_ri={true}
|
||||||
is_small={false}
|
is_small={false}
|
||||||
|
isOpen={calendarOpen}
|
||||||
|
onOpenChange={(open)=>{ setCalendarOpen(open); if (open) setFilterOpen(false); }}
|
||||||
onRangeChange={(s,e)=>{
|
onRangeChange={(s,e)=>{
|
||||||
setStartDate(s); setEndDate(e);
|
setStartDate(s); setEndDate(e);
|
||||||
setDateString(`${s.toISOString().slice(0,10)} ~ ${e.toISOString().slice(0,10)}`);
|
setDateString(`${s.toISOString().slice(0,10)} ~ ${e.toISOString().slice(0,10)}`);
|
||||||
@@ -405,6 +449,8 @@ export default function Page({user}: {user: any}) {
|
|||||||
channel_list={myChannelList}
|
channel_list={myChannelList}
|
||||||
visibleSet={visibleChannelIds}
|
visibleSet={visibleChannelIds}
|
||||||
onChangeVisible={(next) => setVisibleChannelIds(new Set(next))}
|
onChangeVisible={(next) => setVisibleChannelIds(new Set(next))}
|
||||||
|
isOpen={filterOpen}
|
||||||
|
onOpenChange={(open)=>{ setFilterOpen(open); if (open) setCalendarOpen(false); }}
|
||||||
/>
|
/>
|
||||||
<div className="w-2"> </div>
|
<div className="w-2"> </div>
|
||||||
</div>
|
</div>
|
||||||
@@ -596,20 +642,11 @@ export default function Page({user}: {user: any}) {
|
|||||||
<option value="series">기간별</option>
|
<option value="series">기간별</option>
|
||||||
</select>
|
</select>
|
||||||
{seriesMode === 'series' && (
|
{seriesMode === 'series' && (
|
||||||
<>
|
|
||||||
<select className="border-1 border-[#e6e9ef] rounded px-2 py-1" value={seriesInterval} onChange={e => setSeriesInterval(e.target.value as any)}>
|
<select className="border-1 border-[#e6e9ef] rounded px-2 py-1" value={seriesInterval} onChange={e => setSeriesInterval(e.target.value as any)}>
|
||||||
<option value="day">일</option>
|
<option value="day">일</option>
|
||||||
<option value="week">주</option>
|
<option value="week">주</option>
|
||||||
<option value="month">월</option>
|
<option value="month">월</option>
|
||||||
</select>
|
</select>
|
||||||
<button className="border-1 border-[#e6e9ef] rounded px-2 py-1" onClick={async () => {
|
|
||||||
const s = startDate; const e = endDate;
|
|
||||||
const qs = new URLSearchParams({ start: s.toISOString(), end: e.toISOString(), mode: 'series', interval: seriesInterval })
|
|
||||||
const res = await fetch(`/api/contents/mycontent?${qs.toString()}`, { cache: 'no-store' })
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok) setSeriesItems(data.items || [])
|
|
||||||
}}>불러오기</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row justify-start items-center">
|
<div className="flex flex-row justify-start items-center">
|
||||||
@@ -621,7 +658,13 @@ export default function Page({user}: {user: any}) {
|
|||||||
{/* <div className=" p-2 pt-5 text-sm font-normal text-[#F94B37]"> + 0.24%</div> */}
|
{/* <div className=" p-2 pt-5 text-sm font-normal text-[#F94B37]"> + 0.24%</div> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex-1 items-center justify-center flex" ref={chartRef}>
|
<div className="w-full flex-1 items-center justify-center flex" ref={chartRef}>
|
||||||
|
{seriesMode === 'series' && seriesLoading ? (
|
||||||
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-[#e6e9ef]" style={{ borderTopColor: metricColor[selectedMetric].border }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Line data={chartData} options={chartOptions} />
|
<Line data={chartData} options={chartOptions} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
"use client";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
interface NavBarProps {
|
interface NavBarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -9,6 +11,7 @@ interface NavBarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
|
const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
<nav onClick={(e) => e.stopPropagation()} className={`
|
<nav onClick={(e) => e.stopPropagation()} className={`
|
||||||
transition-all duration-300 max-lg:transition-none
|
transition-all duration-300 max-lg:transition-none
|
||||||
@@ -18,7 +21,7 @@ const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
|
|||||||
bg-white
|
bg-white
|
||||||
text-black
|
text-black
|
||||||
p-4
|
p-4
|
||||||
h-[100vh]
|
h-[100dvh]
|
||||||
overflow-y-auto
|
overflow-y-auto
|
||||||
flex-col
|
flex-col
|
||||||
flex
|
flex
|
||||||
@@ -33,7 +36,9 @@ const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
|
|||||||
lg:min-w-[300px]
|
lg:min-w-[300px]
|
||||||
lg:basis-[300px]
|
lg:basis-[300px]
|
||||||
lg:shirink-0
|
lg:shirink-0
|
||||||
lg:ml-5 sm:mr-5`}>
|
lg:ml-5 sm:mr-5`}
|
||||||
|
style={{ paddingBottom: 'max(env(safe-area-inset-bottom), 1rem)' }}
|
||||||
|
>
|
||||||
<div className="flex-0 hidden lg:block">
|
<div className="flex-0 hidden lg:block">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<div className="flex items-center justify-center gap-1 mb-5 p-2 cursor-pointer">
|
<div className="flex items-center justify-center gap-1 mb-5 p-2 cursor-pointer">
|
||||||
@@ -48,17 +53,17 @@ const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
|
|||||||
<div className="flex-0">
|
<div className="flex-0">
|
||||||
<ul>
|
<ul>
|
||||||
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
|
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
|
||||||
<Link href="/usr/1_dashboard" className="flex items-center gap-3 p-2" onClick={() => setIsOpen(false)}>
|
<Link href="/usr/1_dashboard" className={`flex items-center gap-3 p-2 rounded-lg ${pathname?.startsWith('/usr/1_dashboard') ? 'bg-[#FFF3F2] 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">
|
<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 " />
|
<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 ${pathname?.startsWith('/usr/1_dashboard') ? 'stroke-primary-normal' : 'stroke-black'}`} />
|
||||||
</svg>
|
</svg>
|
||||||
<span>대시보드</span>
|
<span>대시보드</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
|
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
|
||||||
<Link href="/usr/2_mychannel" className="flex items-center gap-2 p-2 group-hover:text-primary-normal" onClick={() => setIsOpen(false)}>
|
<Link href="/usr/2_mychannel" className={`flex items-center gap-2 p-2 rounded-lg group-hover:text-primary-normal ${pathname?.startsWith('/usr/2_mychannel') ? 'bg-[#FFF3F2] 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">
|
<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"/>
|
<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={`stroke-none ${pathname?.startsWith('/usr/2_mychannel') ? 'fill-primary-normal' : 'fill-black'} group-hover:fill-primary-normal`}/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>마이채널관리</span>
|
<span>마이채널관리</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -76,10 +81,10 @@ const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</li> */}
|
</li> */}
|
||||||
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
|
<li className="mb-3 p-2 sidebar-menu-hover text-black text-xl font-bold group">
|
||||||
<Link href="/usr/4_noticeboard" className="flex items-center gap-2 p-2 group-hover:text-primary-normal" onClick={() => setIsOpen(false)}>
|
<Link href="/usr/4_noticeboard" className={`flex items-center gap-2 p-2 rounded-lg group-hover:text-primary-normal ${pathname?.startsWith('/usr/4_noticeboard') ? 'bg-[#FFF3F2] text-primary-normal' : ''}`} onClick={() => setIsOpen(false)}>
|
||||||
<div className="w-[25px] h-[24px] flex items-center justify-center">
|
<div className="w-[25px] h-[24px] flex items-center justify-center">
|
||||||
<svg width="20" height="17" viewBox="0 0 20 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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" />
|
<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 ${pathname?.startsWith('/usr/4_noticeboard') ? 'fill-primary-normal' : 'fill-black'} group-hover:fill-primary-normal`} />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span>공지 게시판</span>
|
<span>공지 게시판</span>
|
||||||
@@ -90,9 +95,9 @@ const NavBar: React.FC<NavBarProps> = ({ isOpen, setIsOpen, user }) => {
|
|||||||
<div className="flex-1 flex flex-col justify-end mb-10">
|
<div className="flex-1 flex flex-col justify-end mb-10">
|
||||||
{user?.email === 'wsx204@naver.com' && (
|
{user?.email === 'wsx204@naver.com' && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link href="/admin" className="flex items-center gap-2 p-2 border-1 border-[#e6e9ef] rounded-lg hover:bg-[#F0F0F0]" onClick={() => setIsOpen(false)}>
|
<Link href="/admin" className={`flex items-center gap-2 p-2 border-1 border-[#e6e9ef] rounded-lg hover:bg-[#F0F0F0] ${pathname?.startsWith('/admin') ? 'bg-[#FFF3F2] text-primary-normal border-primary-normal' : ''}`} onClick={() => setIsOpen(false)}>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M12 2L15 8L22 9L17 14L18 21L12 18L6 21L7 14L2 9L9 8L12 2Z" className="stroke-none fill-black"/>
|
<path d="M12 2L15 8L22 9L17 14L18 21L12 18L6 21L7 14L2 9L9 8L12 2Z" className={`${pathname?.startsWith('/admin') ? 'fill-primary-normal' : 'fill-black'} stroke-none`}/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-lg font-bold">관리자</span>
|
<span className="text-lg font-bold">관리자</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
@layer base{
|
@layer base{
|
||||||
html,body{
|
html,body{
|
||||||
|
@apply m-0 p-0; /* remove default browser margin/padding to avoid gaps */
|
||||||
@apply h-full w-full;
|
@apply h-full w-full;
|
||||||
@apply min-w-[500px] min-h-[820px];
|
@apply min-w-[500px] min-h-[820px];
|
||||||
@apply bg-[#F5F5F5];
|
@apply bg-[#F5F5F5];
|
||||||
|
|||||||
30
app/page.tsx
30
app/page.tsx
@@ -11,23 +11,31 @@ export default function Home() {
|
|||||||
signIn("google")
|
signIn("google")
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="bg-[url('/imsi_bg.png')] bg-cover bg-center h-screen">
|
<div className="relative min-h-screen overflow-hidden bg-black">
|
||||||
{/* <video className="bg-cover bg-center h-screen" autoPlay loop muted>
|
<video
|
||||||
<source src="/bgmv.mp4" type="video/mp4" />
|
className="fixed inset-0 w-full h-full object-cover z-0"
|
||||||
Your browser does not support the video tag.
|
autoPlay
|
||||||
</video> */}
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
poster="/imsi_bg.png"
|
||||||
|
>
|
||||||
|
<source src="/everfactory.mp4" type="video/mp4" />
|
||||||
|
</video>
|
||||||
<div className="
|
<div className="
|
||||||
h-screen
|
|
||||||
pt-5 pb-5
|
pt-5 pb-5
|
||||||
|
|
||||||
w-full sm:w-1/2 xl:w-1/3
|
w-full sm:w-1/2 xl:w-1/3
|
||||||
transition-width
|
transition-width
|
||||||
|
|
||||||
fixed right-0
|
fixed right-0 top-0 bottom-0 z-10
|
||||||
shadow-lg
|
shadow-lg
|
||||||
|
|
||||||
min-w-[500px] min-h-[500px]
|
min-w-[500px] min-h-[500px]
|
||||||
" >
|
"
|
||||||
|
style={{ height: '100dvh', paddingBottom: 'max(env(safe-area-inset-bottom), 1.25rem)' }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
bg-black/30 backdrop-blur-lg
|
bg-black/30 backdrop-blur-lg
|
||||||
@@ -46,9 +54,9 @@ export default function Home() {
|
|||||||
<div className="text-white text-4xl font-bold text-center mb-3">콘텐츠 음원 수익<br/>에버팩토리에서 편리하게</div>
|
<div className="text-white text-4xl font-bold text-center mb-3">콘텐츠 음원 수익<br/>에버팩토리에서 편리하게</div>
|
||||||
{/* <div className="text-white text-md text-center mb-3">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi lobortis maximus</div> */}
|
{/* <div className="text-white text-md text-center mb-3">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi lobortis maximus</div> */}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="w-full">
|
||||||
<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="text-white text-md text-center mb-3 cursor-pointer ">Ready to travel with us?</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 className="pointer rounded-full bg-white p-2 flex items-center justify-center gap-2 cursor-pointer w-full" onClick={handleSignIn}> <FcGoogle/> continue with google</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
134
datareq/datareq.js
Normal file
134
datareq/datareq.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// 요청 예시: https://www.everfactory.kr/api/contents/update?date=YYYYMMDD
|
||||||
|
// 사용법
|
||||||
|
// 1) 오늘(가장 최신)부터 특정 일자까지 요청:
|
||||||
|
// node datareq/datareq.js 20251014
|
||||||
|
// 2) 특정 최신일자부터 특정 과거일자까지 요청:
|
||||||
|
// node datareq/datareq.js 20251015 20251001
|
||||||
|
// 환경변수로 호출 간 지연(ms) 조절: RATE_MS=500 node datareq/datareq.js 20251014
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost:9551/api/contents/update";
|
||||||
|
const DEFAULT_RATE_MS = Number(process.env.RATE_MS || 400);
|
||||||
|
|
||||||
|
function isValidYyyyMmDd(value) {
|
||||||
|
return typeof value === "string" && /^\d{8}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateText(text, maxLen = 256) {
|
||||||
|
if (typeof text !== "string") return "";
|
||||||
|
return text.length > maxLen ? text.slice(0, maxLen) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYyyyMmDdToDate(yyyyMmDd) {
|
||||||
|
const year = Number(yyyyMmDd.slice(0, 4));
|
||||||
|
const month = Number(yyyyMmDd.slice(4, 6)) - 1; // 0-based
|
||||||
|
const day = Number(yyyyMmDd.slice(6, 8));
|
||||||
|
// Date(YYYY, M, D)는 로컬 타임존 기준. 간단성을 위해 로컬 기준 사용.
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateToYyyyMmDd(date) {
|
||||||
|
const y = String(date.getFullYear());
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${y}${m}${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, delta) {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + delta);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestForDate(dateStr) {
|
||||||
|
const url = `${BASE_URL}?date=${dateStr}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: "GET" });
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
if (!res.ok) {
|
||||||
|
const preview = truncateText(text, 256);
|
||||||
|
console.error(`[FAIL] ${dateStr} | ${url} -> ${res.status} ${res.statusText} ${preview ? `| ${preview}` : ""}`);
|
||||||
|
return { ok: false, status: res.status, body: text };
|
||||||
|
}
|
||||||
|
const preview = truncateText(text, 256);
|
||||||
|
console.log(`[OK] ${dateStr} | ${url} -> ${preview || res.status}`);
|
||||||
|
return { ok: true, status: res.status, body: text };
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[ERROR] ${dateStr} | ${url} -> ${truncateText(errMsg, 256)}`);
|
||||||
|
return { ok: false, error: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.log("사용법: node datareq/datareq.js <끝일자YYYYMMDD> [시작(최신)일자YYYYMMDD]");
|
||||||
|
console.log("예시1: node datareq/datareq.js 20251014");
|
||||||
|
console.log("예시2: node datareq/datareq.js 20251015 20251001");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newestStr; // 최신일자 (내림차순 시작점)
|
||||||
|
let oldestStr; // 종료일자 (내림차순 끝점)
|
||||||
|
|
||||||
|
if (args.length === 1) {
|
||||||
|
const today = new Date();
|
||||||
|
newestStr = formatDateToYyyyMmDd(today);
|
||||||
|
oldestStr = args[0];
|
||||||
|
} else {
|
||||||
|
// args.length >= 2 인 경우: [최신일자, 종료일자]
|
||||||
|
newestStr = args[0];
|
||||||
|
oldestStr = args[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidYyyyMmDd(newestStr) || !isValidYyyyMmDd(oldestStr)) {
|
||||||
|
console.error("날짜 형식은 YYYYMMDD 여야 합니다.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newestDate = parseYyyyMmDdToDate(newestStr);
|
||||||
|
let oldestDate = parseYyyyMmDdToDate(oldestStr);
|
||||||
|
|
||||||
|
// 최신 >= 종료 가 아니면 자동 교환
|
||||||
|
if (newestDate < oldestDate) {
|
||||||
|
const tmp = newestDate;
|
||||||
|
newestDate = oldestDate;
|
||||||
|
oldestDate = tmp;
|
||||||
|
newestStr = formatDateToYyyyMmDd(newestDate);
|
||||||
|
oldestStr = formatDateToYyyyMmDd(oldestDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`요청 범위: 최신 ${newestStr} → 종료 ${oldestStr} (내림차순)`);
|
||||||
|
console.log(`요청 간 지연: ${DEFAULT_RATE_MS}ms`);
|
||||||
|
|
||||||
|
let current = newestDate;
|
||||||
|
while (current >= oldestDate) {
|
||||||
|
const dateStr = formatDateToYyyyMmDd(current);
|
||||||
|
await requestForDate(dateStr);
|
||||||
|
// 너무 빠른 연속 호출 방지
|
||||||
|
await sleep(DEFAULT_RATE_MS);
|
||||||
|
current = addDays(current, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("모든 요청이 완료되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node 18+ 환경 가정: fetch 전역 제공. 없으면 종료 안내.
|
||||||
|
if (typeof fetch !== "function") {
|
||||||
|
console.error("현재 Node 런타임에 fetch가 없습니다. Node 18+를 사용하거나 폴리필을 추가하세요.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("예상치 못한 오류:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
39
datareq/datareq_latest.js
Normal file
39
datareq/datareq_latest.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost:9551/api/contents/update";
|
||||||
|
|
||||||
|
function truncateText(text, maxLen = 256) {
|
||||||
|
if (typeof text !== "string") return "";
|
||||||
|
return text.length > maxLen ? text.slice(0, maxLen) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestLatest() {
|
||||||
|
const url = `${BASE_URL}?date=latest`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: "GET" });
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
if (!res.ok) {
|
||||||
|
const preview = truncateText(text, 256);
|
||||||
|
console.error(`[FAIL] latest | ${url} -> ${res.status} ${res.statusText} ${preview ? `| ${preview}` : ""}`);
|
||||||
|
return { ok: false, status: res.status, body: text };
|
||||||
|
}
|
||||||
|
const preview = truncateText(text, 256);
|
||||||
|
console.log(`[OK] latest | ${url} -> ${preview || res.status}`);
|
||||||
|
return { ok: true, status: res.status, body: text };
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[ERROR] latest | ${url} -> ${truncateText(errMsg, 256)}`);
|
||||||
|
return { ok: false, error: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node 18+ 환경 가정: fetch 전역 제공. 없으면 종료 안내.
|
||||||
|
if (typeof fetch !== "function") {
|
||||||
|
console.error("현재 Node 런타임에 fetch가 없습니다. Node 18+를 사용하거나 폴리필을 추가하세요.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLatest().catch((err) => {
|
||||||
|
console.error("예상치 못한 오류:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
1
dumps/ContentDayView_20251015_074150.sql
Normal file
1
dumps/ContentDayView_20251015_074150.sql
Normal file
File diff suppressed because one or more lines are too long
@@ -10,10 +10,12 @@ export default auth(async (req) => {
|
|||||||
// 경로 추출
|
// 경로 추출
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
console.log("request host:", req.headers.get('host'));
|
||||||
// 로컬 접속 확인 (특정 경로만 우회)
|
// 로컬 접속 확인 (특정 경로만 우회)
|
||||||
const isLocal = req.headers.get('host')?.includes('localhost') ||
|
const isLocal = req.headers.get('host')?.includes('localhost') ||
|
||||||
req.headers.get('host')?.includes('127.0.0.1');
|
req.headers.get('host')?.includes('127.0.0.1');
|
||||||
|
|
||||||
|
|
||||||
if (isLocal && pathname.startsWith("/api/contents")) {
|
if (isLocal && pathname.startsWith("/api/contents")) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,16 @@ function formatKoreanDateFromYMD(ymd) {
|
|||||||
return `${yyyy}. ${mm}. ${dd}.`;
|
return `${yyyy}. ${mm}. ${dd}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// '2025. 9. 1.' → '20250901' 로 변환
|
||||||
|
function dotKoreanDateToYMD(dot) {
|
||||||
|
const m = dot?.match(/^(\d{4})\.\s*(\d{1,2})\.\s*(\d{1,2})\.?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const yyyy = m[1];
|
||||||
|
const mm = String(parseInt(m[2], 10)).padStart(2, '0');
|
||||||
|
const dd = String(parseInt(m[3], 10)).padStart(2, '0');
|
||||||
|
return `${yyyy}${mm}${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 입력칸의 기존 값을 지우고 새 값 입력
|
// 입력칸의 기존 값을 지우고 새 값 입력
|
||||||
async function clearAndType(inputLocator, page, value) {
|
async function clearAndType(inputLocator, page, value) {
|
||||||
await inputLocator.click();
|
await inputLocator.click();
|
||||||
@@ -179,6 +189,13 @@ async function configureDateRangeSingleDay(page, ymdTarget) {
|
|||||||
await clearAndType(startInput, page, endVal);
|
await clearAndType(startInput, page, endVal);
|
||||||
startVal = endVal;
|
startVal = endVal;
|
||||||
} else if (ymdTarget) {
|
} else if (ymdTarget) {
|
||||||
|
// 최신 종료일(=현재 UI가 가진 가장 최근 일자)과 비교하여 이후면 에러
|
||||||
|
const latestEndVal = await endInput.inputValue();
|
||||||
|
const latestYmd = dotKoreanDateToYMD(latestEndVal);
|
||||||
|
if (!latestYmd) throw new Error('최신 종료일 파싱에 실패했습니다.');
|
||||||
|
if (!/^\d{8}$/.test(ymdTarget)) throw new Error('잘못된 날짜 형식입니다. (예: 20250901)');
|
||||||
|
if (ymdTarget > latestYmd) throw new Error(`요청한 날짜(${ymdTarget})가 최신 데이터(${latestYmd})보다 이후입니다.`);
|
||||||
|
|
||||||
const formatted = formatKoreanDateFromYMD(ymdTarget);
|
const formatted = formatKoreanDateFromYMD(ymdTarget);
|
||||||
if (!formatted) throw new Error(`잘못된 날짜 형식입니다. (예: 20250901)`);
|
if (!formatted) throw new Error(`잘못된 날짜 형식입니다. (예: 20250901)`);
|
||||||
// 시작/종료 모두 동일 날짜로 설정
|
// 시작/종료 모두 동일 날짜로 설정
|
||||||
@@ -277,7 +294,8 @@ const server = http.createServer((req, res) => {
|
|||||||
const result = await runFreshExport(ymd);
|
const result = await runFreshExport(ymd);
|
||||||
const payload = {
|
const payload = {
|
||||||
...result,
|
...result,
|
||||||
date: ymd === 'latest' ? result.endDate : (ymd || result.endDate),
|
// 요청된 ymd 대신 실제 적용된 기간의 종료일(=단일일자)로 반환
|
||||||
|
date: result.endDate,
|
||||||
};
|
};
|
||||||
sendJson(res, 200, payload);
|
sendJson(res, 200, payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
BIN
public/everfactory.mp4
Normal file
BIN
public/everfactory.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user