브랜드 분석 페이지 수정중 .
parent
5b2450ed60
commit
3774221202
258
index.css
258
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
index.html
20
index.html
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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> },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue