diff --git a/index.css b/index.css index bb77d41..39db717 100644 --- a/index.css +++ b/index.css @@ -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; + } +} diff --git a/index.html b/index.html index d79ed9d..2be9c52 100755 --- a/index.html +++ b/index.html @@ -6,6 +6,26 @@ CASTAD + diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d576eda..72a1fc1 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -57,7 +57,7 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome }) => const menuItems = [ { id: '대시보드', label: '대시보드', disabled: true, icon: }, { id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', disabled: false, icon: }, - { id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: true, icon: }, + { id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: false, icon: }, { id: '에셋 관리', label: '에셋 관리', disabled: true, icon: }, { id: '내 펜션', label: '내 펜션', disabled: true, icon: }, { id: '계정 설정', label: '계정 설정', disabled: true, icon: }, diff --git a/src/pages/Analysis/GeometricChart.tsx b/src/pages/Analysis/GeometricChart.tsx new file mode 100644 index 0000000..af891f0 --- /dev/null +++ b/src/pages/Analysis/GeometricChart.tsx @@ -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 = ({ 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 ( +
+ + + + + + + + + {[1, 0.75, 0.5, 0.25].map((scale, i) => ( + + ))} + + {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 ( + + ); + })} + + + + {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 ( + + {isHigh && ( + + )} + + + ) + })} + + {labels.map((l, i) => { + const isHigh = l.score >= 90; + return ( + + + {l.text} + + + {l.sub} + + + ); + })} + +
+ ); +}; + +export default GeometricChart; diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx new file mode 100644 index 0000000..7919c8d --- /dev/null +++ b/src/pages/Dashboard/ADO2ContentsPage.tsx @@ -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 = ({ onBack }) => { + const [videos, setVideos] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+

ADO2 콘텐츠

+ 총 {total}개 +
+ + {/* Content Grid */} + {loading ? ( +
+
+

콘텐츠를 불러오는 중...

+
+ ) : error ? ( +
+

{error}

+ +
+ ) : videos.length === 0 ? ( +
+

생성된 콘텐츠가 없습니다.

+
+ ) : ( + <> +
+ {videos.map((video) => ( +
+ {/* Video Thumbnail */} +
+ {video.result_movie_url ? ( +
+ + {/* Card Info */} +
+
+

+ {formatTitle(video.store_name, video.created_at)} +

+

+ {formatDate(video.created_at)} +

+
+ + {/* Action Buttons */} +
+ + +
+
+
+ ))} +
+ + {/* Pagination - 항상 표시 */} +
+ + {page} / {totalPages} + +
+ + )} +
+ ); +}; + +export default ADO2ContentsPage; diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index ccb142e..878f6ac 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -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 = ({ 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 = ({ return ; case '비즈니스 설정': return ; + case 'ADO2 콘텐츠': + return ( + setActiveItem('새 프로젝트 만들기')} + /> + ); case '새 프로젝트 만들기': // 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시 if (wizardStep === 0 || wizardStep === -1) { @@ -311,15 +336,15 @@ const GenerationFlow: React.FC = ({ // 브랜드 분석(0)일 때는 전체 페이지 스크롤 const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0; - // 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0) - const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || isBrandAnalysis; + // 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠 + const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || isBrandAnalysis; // 브랜드 분석일 때는 전체 화면 스크롤 if (isBrandAnalysis) { return (
{showSidebar && ( - + )}
{renderContent()} @@ -331,7 +356,7 @@ const GenerationFlow: React.FC = ({ return (
{showSidebar && ( - + )}
{renderContent()} diff --git a/src/types/api.ts b/src/types/api.ts index 6bcf50b..fa4cbd1 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; +} + diff --git a/src/utils/api.ts b/src/utils/api.ts index e4d246a..c793fc0 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -11,6 +11,7 @@ import { VideoGenerateResponse, VideoStatusResponse, VideoDownloadResponse, + VideosListResponse, ImageUrlItem, ImageUploadResponse, KakaoLoginUrlResponse, @@ -268,6 +269,19 @@ export async function downloadVideo(taskId: string): Promise { + 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; diff --git a/vite.config.ts b/vite.config.ts index 85b7cdb..4b7dbdf 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -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]' + } + } } });