Compare commits
3 Commits
76db85a22f
...
9c0aeb73e8
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c0aeb73e8 | |||
| 0ab4b037eb | |||
| ddb70018b9 |
@@ -28,10 +28,16 @@ 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 session = await auth();
|
const hostHeader = (request.headers.get('host') || '').toLowerCase()
|
||||||
if (!session?.user?.email) {
|
const isLocal = hostHeader.includes('localhost') || hostHeader.includes('127.0.0.1') || urlObj.hostname === 'localhost' || urlObj.hostname === '127.0.0.1'
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
if (!isLocal) {
|
||||||
|
// 세션 사용자 확인 (핸들 매핑용)
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
@@ -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 },
|
||||||
views,
|
|
||||||
validViews,
|
|
||||||
premiumViews,
|
|
||||||
watchTime,
|
|
||||||
},
|
},
|
||||||
|
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,
|
||||||
|
validViews,
|
||||||
|
premiumViews,
|
||||||
|
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]);
|
||||||
|
|
||||||
// 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제하되
|
// 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제하되
|
||||||
// 초기 로딩 등으로 가시 채널이 비어있을 땐 해제하지 않음.
|
// 초기 로딩 등으로 가시 채널이 비어있을 땐 해제하지 않음.
|
||||||
// 가시 채널이 채워지고 선택이 비어있으면 전체 선택으로 초기화.
|
// 가시 채널이 채워지고 선택이 비어있으면 전체 선택으로 초기화.
|
||||||
@@ -364,7 +404,7 @@ export default function Page({user}: {user: any}) {
|
|||||||
py-2
|
py-2
|
||||||
">
|
">
|
||||||
<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 +434,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 +447,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 +640,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 +656,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}>
|
||||||
<Line data={chartData} options={chartOptions} />
|
{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} />
|
||||||
|
)}
|
||||||
</div>
|
</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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user