브랜드 분석 페이지 수정중 .

main
hbyang 2026-01-26 16:18:45 +09:00
parent 5b2450ed60
commit 3774221202
9 changed files with 717 additions and 6 deletions

258
index.css
View File

@ -535,7 +535,9 @@
@media (min-width: 768px) {
.sidebar {
position: relative;
position: sticky;
top: 0;
flex-shrink: 0;
}
}
@ -5791,3 +5793,257 @@
min-height: 80px;
}
}
/* =====================================================
ADO2 Contents Page Styles
===================================================== */
.ado2-contents-page {
padding: 32px;
min-height: 100%;
background-color: var(--color-bg-darker);
}
.ado2-contents-header {
display: flex;
align-items: baseline;
gap: 16px;
margin-bottom: 32px;
}
.ado2-contents-title {
font-size: 32px;
font-weight: 700;
color: var(--color-text-white);
letter-spacing: -0.6%;
}
.ado2-contents-count {
font-size: 17px;
font-weight: 500;
color: #9BCACC;
}
.ado2-contents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.ado2-content-card {
display: flex;
flex-direction: column;
border-radius: 20px;
overflow: hidden;
background-color: #034A4D;
}
.content-card-thumbnail {
width: 100%;
aspect-ratio: 9 / 16;
background-color: #01393B;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.content-video-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.content-no-video {
color: #6AB0B3;
display: flex;
align-items: center;
justify-content: center;
}
.content-card-info {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.content-card-text {
display: flex;
flex-direction: column;
gap: 8px;
}
.content-card-title {
font-size: 17px;
font-weight: 700;
color: var(--color-text-white);
letter-spacing: -0.6%;
margin: 0;
}
.content-card-date {
font-size: 12px;
font-weight: 400;
color: #6AB0B3;
letter-spacing: -0.6%;
margin: 0;
}
.content-card-actions {
display: flex;
gap: 6px;
}
.content-download-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px 10px 10px 8px;
height: 34px;
background-color: #462E64;
border: none;
border-radius: 8px;
color: #F7F1FE;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.content-download-btn:hover:not(:disabled) {
background-color: #563d7a;
}
.content-download-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.content-download-btn svg {
stroke: #F7F1FE;
}
.content-delete-btn {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background-color: #01393B;
border: none;
border-radius: 8px;
color: #6AB0B3;
cursor: pointer;
transition: background-color 0.2s;
}
.content-delete-btn:hover {
background-color: #024648;
}
.content-delete-btn svg {
stroke: #6AB0B3;
}
/* Loading, Error, Empty States */
.ado2-contents-loading,
.ado2-contents-error,
.ado2-contents-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 16px;
color: #9BCACC;
}
.ado2-contents-loading .loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #01393B;
border-top-color: #6AB0B3;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.retry-btn {
padding: 10px 20px;
background-color: #034A4D;
border: 1px solid #6AB0B3;
border-radius: 8px;
color: #6AB0B3;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-btn:hover {
background-color: #024648;
}
/* Pagination */
.ado2-contents-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 32px;
padding-bottom: 32px;
}
.pagination-btn {
padding: 10px 20px;
background-color: #034A4D;
border: 1px solid #6AB0B3;
border-radius: 8px;
color: #6AB0B3;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.pagination-btn:hover:not(:disabled) {
background-color: #024648;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
color: #9BCACC;
font-size: 14px;
}
/* Responsive */
@media (max-width: 768px) {
.ado2-contents-page {
padding: 16px;
}
.ado2-contents-header {
flex-direction: column;
gap: 8px;
}
.ado2-contents-title {
font-size: 24px;
}
.ado2-contents-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
}

View File

@ -6,6 +6,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>CASTAD</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
bg: '#011c1e',
card: '#083336',
cardHover: '#0b3f42',
accent: '#94FBE0',
purple: '#a855f7',
purpleHover: '#9333ea',
text: '#e2e8f0',
muted: '#94a3b8'
}
}
}
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">

View File

@ -57,7 +57,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome }) =>
const menuItems = [
{ id: '대시보드', label: '대시보드', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
{ id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
{ id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
{ id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
{ id: '에셋 관리', label: '에셋 관리', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
{ id: '내 펜션', label: '내 펜션', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> },
{ id: '계정 설정', label: '계정 설정', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },

View File

@ -0,0 +1,155 @@
import React from 'react';
export interface USP {
label: string;
subLabel: string;
score: number;
description: string;
}
interface GeometricChartProps {
data: USP[];
}
export const GeometricChart: React.FC<GeometricChartProps> = ({ data }) => {
const size = 500;
const center = size / 2;
const radius = 110;
const sides = data.length;
const accentColor = "#94FBE0";
const getPoints = (r: number) => {
return data.map((_, i) => {
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + r * Math.cos(angle);
const y = center + r * Math.sin(angle);
return `${x},${y}`;
}).join(' ');
};
const getDataPoints = () => {
return data.map((item, i) => {
const normalizedScore = item.score / 100;
const r = radius * normalizedScore;
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + r * Math.cos(angle);
const y = center + r * Math.sin(angle);
return `${x},${y}`;
}).join(' ');
};
const labelRadius = radius + 55;
const labels = data.map((item, i) => {
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + labelRadius * Math.cos(angle);
const y = center + labelRadius * Math.sin(angle);
let anchor: 'start' | 'middle' | 'end' = 'middle';
if (x < center - 20) anchor = 'end';
if (x > center + 20) anchor = 'start';
return { x, y, text: item.label, sub: item.subLabel, anchor, score: item.score };
});
return (
<div className="flex flex-col items-center justify-center py-2 relative w-full h-full">
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-auto max-w-[500px]" style={{ overflow: 'visible' }}>
<defs>
<radialGradient id="polyGradient" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
<stop offset="0%" stopColor={accentColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={accentColor} stopOpacity="0.05" />
</radialGradient>
</defs>
{[1, 0.75, 0.5, 0.25].map((scale, i) => (
<polygon
key={i}
points={getPoints(radius * scale)}
fill="none"
stroke={accentColor}
strokeOpacity={0.25 - (0.02 * i)}
strokeWidth="1"
strokeDasharray={i === 0 ? "0" : "4 2"}
/>
))}
{data.map((_, i) => {
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + radius * Math.cos(angle);
const y = center + radius * Math.sin(angle);
return (
<line
key={i}
x1={center}
y1={center}
x2={x}
y2={y}
stroke={accentColor}
strokeOpacity="0.25"
strokeWidth="1"
/>
);
})}
<polygon
points={getDataPoints()}
fill="url(#polyGradient)"
stroke={accentColor}
strokeWidth="2.5"
strokeLinejoin="round"
/>
{data.map((item, i) => {
const normalizedScore = item.score / 100;
const r = radius * normalizedScore;
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + r * Math.cos(angle);
const y = center + r * Math.sin(angle);
const isHigh = item.score >= 90;
return (
<g key={i}>
{isHigh && (
<circle cx={x} cy={y} r="10" fill={accentColor} fillOpacity="0.4" />
)}
<circle cx={x} cy={y} r={isHigh ? 5 : 4} fill="#fff" />
</g>
)
})}
{labels.map((l, i) => {
const isHigh = l.score >= 90;
return (
<g key={i}>
<text
x={l.x}
y={l.y - 7}
textAnchor={l.anchor}
fill={isHigh ? "#fff" : "#e2e8f0"}
fontSize={isHigh ? "14" : "12"}
fontWeight={isHigh ? "700" : "600"}
className="tracking-tight"
style={{ fontFamily: "sans-serif" }}
>
{l.text}
</text>
<text
x={l.x}
y={l.y + 9}
textAnchor={l.anchor}
fill={isHigh ? accentColor : "#94a3b8"}
fontSize={isHigh ? "11" : "10"}
fontWeight={isHigh ? "600" : "400"}
className="uppercase tracking-wider"
>
{l.sub}
</text>
</g>
);
})}
</svg>
</div>
);
};
export default GeometricChart;

View File

@ -0,0 +1,211 @@
import React, { useState, useEffect } from 'react';
import { getVideosList } from '../../utils/api';
import { VideoListItem } from '../../types/api';
interface ADO2ContentsPageProps {
onBack: () => void;
}
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1);
const pageSize = 12;
useEffect(() => {
fetchVideos();
}, [page]);
const fetchVideos = async () => {
setLoading(true);
setError(null);
try {
const response = await getVideosList(page, pageSize);
// result_movie_url이 있는 비디오만 필터링 (빈/더미 데이터 제외)
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
setVideos(validVideos);
setTotal(response.total);
setTotalPages(response.total_pages);
setHasNext(response.has_next);
setHasPrev(response.has_prev);
} catch (err) {
console.error('Failed to fetch videos:', err);
setError('콘텐츠를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}.${month}.${day}${hours}:${minutes}`;
};
const formatTitle = (storeName: string, dateString: string) => {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${storeName} ${month}/${day} ${hours}:${minutes}`;
};
const handleDownload = async (videoUrl: string, storeName: string) => {
try {
const response = await fetch(videoUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${storeName}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download failed:', err);
alert('다운로드에 실패했습니다.');
}
};
const handleDelete = async (taskId: string) => {
if (!confirm('이 콘텐츠를 삭제하시겠습니까?')) return;
// TODO: 삭제 API 연동
alert('삭제 기능은 아직 구현되지 않았습니다.');
};
return (
<div className="ado2-contents-page">
{/* Header */}
<div className="ado2-contents-header">
<h1 className="ado2-contents-title">ADO2 </h1>
<span className="ado2-contents-count"> {total}</span>
</div>
{/* Content Grid */}
{loading ? (
<div className="ado2-contents-loading">
<div className="loading-spinner"></div>
<p> ...</p>
</div>
) : error ? (
<div className="ado2-contents-error">
<p>{error}</p>
<button onClick={fetchVideos} className="retry-btn"> </button>
</div>
) : videos.length === 0 ? (
<div className="ado2-contents-empty">
<p> .</p>
</div>
) : (
<>
<div className="ado2-contents-grid">
{videos.map((video) => (
<div key={video.task_id} className="ado2-content-card">
{/* Video Thumbnail */}
<div className="content-card-thumbnail">
{video.result_movie_url ? (
<video
src={video.result_movie_url}
className="content-video-preview"
muted
playsInline
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
) : (
<div className="content-no-video">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<line x1="7" y1="2" x2="7" y2="22"/>
<line x1="17" y1="2" x2="17" y2="22"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="17" x2="22" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
</svg>
</div>
)}
</div>
{/* Card Info */}
<div className="content-card-info">
<div className="content-card-text">
<h3 className="content-card-title">
{formatTitle(video.store_name, video.created_at)}
</h3>
<p className="content-card-date">
{formatDate(video.created_at)}
</p>
</div>
{/* Action Buttons */}
<div className="content-card-actions">
<button
className="content-download-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
disabled={!video.result_movie_url}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span></span>
</button>
<button
className="content-delete-btn"
onClick={() => handleDelete(video.task_id)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
<line x1="8" y1="8" x2="8" y2="14"/>
<line x1="12" y1="8" x2="12" y2="14"/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
{/* Pagination - 항상 표시 */}
<div className="ado2-contents-pagination">
<button
className="pagination-btn"
onClick={() => setPage(p => p - 1)}
disabled={!hasPrev}
>
</button>
<span className="pagination-info">{page} / {totalPages}</span>
<button
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={!hasNext}
>
</button>
</div>
</>
)}
</div>
);
};
export default ADO2ContentsPage;

View File

@ -7,6 +7,7 @@ import CompletionContent from './CompletionContent';
import DashboardContent from './DashboardContent';
import BusinessSettingsContent from './BusinessSettingsContent';
import UrlInputContent from './UrlInputContent';
import ADO2ContentsPage from './ADO2ContentsPage';
import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, CrawlingResponse } from '../../types/api';
@ -207,6 +208,24 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
}, [activeItem]);
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
const handleNavigate = (item: string) => {
if (item === '새 프로젝트 만들기') {
// 기존 프로젝트 데이터 초기화
clearAllProjectStorage();
localStorage.removeItem(ANALYSIS_DATA_KEY);
setWizardStep(-2);
setSongTaskId(null);
setImageTaskId(null);
setAnalysisData(null);
setImageList([]);
setVideoGenerationStatus('idle');
setVideoGenerationProgress(0);
setAnalysisError(null);
}
setActiveItem(item);
};
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
const renderWizardContent = () => {
switch (wizardStep) {
@ -287,6 +306,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return <DashboardContent />;
case '비즈니스 설정':
return <BusinessSettingsContent />;
case 'ADO2 콘텐츠':
return (
<ADO2ContentsPage
onBack={() => setActiveItem('새 프로젝트 만들기')}
/>
);
case '새 프로젝트 만들기':
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
if (wizardStep === 0 || wizardStep === -1) {
@ -311,15 +336,15 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0)
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || isBrandAnalysis;
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || isBrandAnalysis;
// 브랜드 분석일 때는 전체 화면 스크롤
if (isBrandAnalysis) {
return (
<div className="analysis-page-wrapper">
{showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={setActiveItem} onHome={handleHome} />
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} />
)}
<main className="analysis-page-main">
{renderContent()}
@ -331,7 +356,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return (
<div className={`flex w-full bg-[#0d1416] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
{showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={setActiveItem} onHome={handleHome} />
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} />
)}
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-y-auto' : 'h-full overflow-hidden'}`}>
{renderContent()}

View File

@ -214,3 +214,23 @@ export interface UserMeResponse {
created_at: string;
}
// 비디오 목록 아이템
export interface VideoListItem {
store_name: string;
region: string;
task_id: string;
result_movie_url: string;
created_at: string;
}
// 비디오 목록 응답
export interface VideosListResponse {
items: VideoListItem[];
total: number;
page: number;
page_size: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}

View File

@ -11,6 +11,7 @@ import {
VideoGenerateResponse,
VideoStatusResponse,
VideoDownloadResponse,
VideosListResponse,
ImageUrlItem,
ImageUploadResponse,
KakaoLoginUrlResponse,
@ -268,6 +269,19 @@ export async function downloadVideo(taskId: string): Promise<VideoDownloadRespon
return response.json();
}
// 비디오 목록 조회 API
export async function getVideosList(page: number = 1, pageSize: number = 10): Promise<VideosListResponse> {
const response = await fetch(`${API_URL}/videos/?page=${page}&page_size=${pageSize}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 이미지 업로드 API (multipart/form-data)
// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음)
const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000;

View File

@ -16,5 +16,15 @@ export default defineConfig({
alias: {
'@': resolve(__dirname, './src'),
}
},
build: {
rollupOptions: {
output: {
// JS/CSS 파일에 해시 추가 (캐시 버스팅)
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]'
}
}
}
});