castad front 작업

main
hbyang 2025-12-23 11:01:31 +09:00
parent aa39cde4c6
commit a42562279e
30 changed files with 4332 additions and 0 deletions

View File

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(mv:*)",
"Bash(rmdir:*)",
"Bash(rm:*)"
]
}
}

6
.env Normal file
View File

@ -0,0 +1,6 @@
# Local server
VITE_PORT=3000
VITE_HOST=localhost
# API server
VITE_API_URL=http://40.82.133.44

24
.gitignore vendored Executable file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

13
Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

12
docker-compose.yaml Normal file
View File

@ -0,0 +1,12 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
env_file:
- .env

141
index.html Executable file
View File

@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<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>
<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">
<style>
:root {
--app-height: 100%;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: #121a1d;
}
body {
font-family: 'Noto Sans KR', sans-serif;
-webkit-font-smoothing: antialiased;
}
.font-serif-logo {
font-family: 'Playfair Display', serif;
}
/* Full-page snap scroll container */
.snap-container {
height: 100dvh;
width: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.snap-container::-webkit-scrollbar {
display: none;
}
.snap-section {
height: 100dvh;
width: 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Utility to ensure content fits without vertical overflow */
.content-safe-area {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem 1rem;
width: 100%;
max-width: 1280px;
margin: 0 auto;
}
/* Floating heart animation */
@keyframes floatUp {
0% { transform: translateY(0) scale(0.5) translateX(0); opacity: 1; }
100% { transform: translateY(-160px) scale(1.6) translateX(var(--tx, 20px)); opacity: 0; }
}
.floating-heart {
position: absolute;
pointer-events: none;
animation: floatUp 2s ease-out forwards;
color: #ff4d4d;
font-size: 24px;
z-index: 50;
}
/* Custom scrollbar styling */
.custom-scrollbar {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(166, 255, 234, 0.3) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(166, 255, 234, 0.3);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(166, 255, 234, 0.5);
}
/* No scrollbar utility */
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Slide in animation - 오른쪽에서 왼쪽으로 */
@keyframes slideInFromRight {
0% {
opacity: 0;
transform: translateX(100px);
max-width: 0;
}
50% {
max-width: 500px;
}
100% {
opacity: 1;
transform: translateX(0);
max-width: 600px;
}
}
.animate-slide-in {
animation: slideInFromRight 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
</style>
<script type="importmap">
{
"imports": {
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-[#121a1d]">
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

5
metadata.json Executable file
View File

@ -0,0 +1,5 @@
{
"name": "CASTAD - Marketing Automation",
"description": "A high-fidelity landing page for CASTAD, a location-based marketing automation service, featuring a smooth full-page snap scroll experience.",
"requestFramePermissions": []
}

1767
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Executable file
View File

@ -0,0 +1,21 @@
{
"name": "castad---marketing-automation",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

113
src/App.tsx Executable file
View File

@ -0,0 +1,113 @@
import React, { useRef, useState } from 'react';
import HeroSection from './pages/Landing/HeroSection';
import WelcomeSection from './pages/Landing/WelcomeSection';
import DisplaySection from './pages/Landing/DisplaySection';
import LoadingSection from './pages/Analysis/LoadingSection';
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
import LoginSection from './pages/Login/LoginSection';
import GenerationFlow from './pages/Dashboard/GenerationFlow';
import { crawlUrl } from './utils/api';
import { CrawlingResponse } from './types/api';
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
const App: React.FC = () => {
const containerRef = useRef<HTMLElement>(null);
const [viewMode, setViewMode] = useState<ViewMode>('landing');
const [initialTab, setInitialTab] = useState('새 프로젝트 만들기');
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const scrollToSection = (index: number) => {
if (containerRef.current) {
const h = containerRef.current.clientHeight;
containerRef.current.scrollTo({
top: h * index,
behavior: 'smooth'
});
}
};
const handleStartAnalysis = async (url: string) => {
if (!url.trim()) return;
setViewMode('loading');
setError(null);
try {
const data = await crawlUrl(url);
setAnalysisData(data);
setViewMode('analysis');
} catch (err) {
console.error('Crawling failed:', err);
setError('분석 중 오류가 발생했습니다. 다시 시도해주세요.');
setViewMode('landing');
}
};
const handleToLogin = () => {
setViewMode('login');
};
const handleLoginSuccess = () => {
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
};
const handleGoBack = () => {
setViewMode('landing');
};
if (viewMode === 'loading') {
return <LoadingSection />;
}
if (viewMode === 'analysis' && analysisData) {
return (
<AnalysisResultSection
onBack={handleGoBack}
onGenerate={handleToLogin}
data={analysisData}
/>
);
}
if (viewMode === 'login') {
return <LoginSection onBack={() => setViewMode('analysis')} onLogin={handleLoginSuccess} />;
}
if (viewMode === 'generation_flow') {
return (
<GenerationFlow
onHome={handleGoBack}
initialActiveItem={initialTab}
initialImageList={analysisData?.image_list || []}
businessInfo={analysisData?.processed_info}
/>
);
}
return (
<main className="snap-container" ref={containerRef}>
<section className="snap-section">
<HeroSection
onAnalyze={handleStartAnalysis}
onNext={() => scrollToSection(1)}
error={error}
/>
</section>
<section className="snap-section">
<WelcomeSection
onStartClick={() => scrollToSection(0)}
onNext={() => scrollToSection(0)}
/>
</section>
<section className="snap-section">
<DisplaySection onStartClick={() => scrollToSection(0)} />
</section>
</main>
);
};
export default App;

21
src/components/Header.tsx Executable file
View File

@ -0,0 +1,21 @@
import React from 'react';
const Header: React.FC = () => {
return (
<header className="w-full py-4 sm:py-6 px-6 sm:px-8 flex justify-between items-center absolute top-0 left-0 z-20">
<div className="font-serif-logo italic text-xl sm:text-2xl font-bold tracking-tight text-white">
CASTAD
</div>
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full overflow-hidden border border-gray-700 shadow-sm">
<img
src="https://picsum.photos/seed/user/100/100"
alt="User Profile"
className="w-full h-full object-cover"
/>
</div>
</header>
);
};
export default Header;

169
src/components/Sidebar.tsx Executable file
View File

@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react';
interface SidebarItemProps {
icon: React.ReactNode;
label: string;
isActive?: boolean;
isCollapsed: boolean;
onClick?: () => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, onClick }) => {
const baseClasses = "flex items-center gap-3 px-3 py-2.5 sm:py-3 rounded-xl transition-all cursor-pointer group mb-1 relative overflow-hidden";
const activeClasses = "bg-[#a6ffea] text-[#121a1d]";
const inactiveClasses = "text-gray-400 hover:bg-white/5 hover:text-white";
return (
<div
onClick={onClick}
className={`${baseClasses} ${isActive ? activeClasses : inactiveClasses} ${isCollapsed ? 'justify-center w-10 h-10 sm:w-12 sm:h-12 mx-auto px-0' : ''}`}
title={isCollapsed ? label : ""}
>
<div className={`shrink-0 flex items-center justify-center transition-colors ${isActive ? 'text-[#121a1d]' : ''}`}>
{icon}
</div>
{!isCollapsed && <span className="text-xs sm:text-sm font-bold whitespace-nowrap">{label}</span>}
</div>
);
};
interface SidebarProps {
activeItem: string;
onNavigate: (id: string) => void;
}
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
// 모바일에서는 기본적으로 사이드바 숨기기
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 768) {
setIsMobileOpen(false);
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// 네비게이션 시 모바일에서 사이드바 닫기
const handleNavigate = (id: string) => {
onNavigate(id);
if (window.innerWidth < 768) {
setIsMobileOpen(false);
}
};
const menuItems = [
{ id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', 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: '내 보관함', label: '내 보관함', 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: '에셋 관리', 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: '내 펜션', 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: '계정 설정', 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> },
{ id: '대시보드', label: '대시보드', 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: '비즈니스 설정', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1-2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> },
];
return (
<>
{/* 모바일 햄버거 버튼 */}
<button
onClick={() => setIsMobileOpen(true)}
className="fixed top-4 left-4 z-40 p-2 bg-[#1c2a2e] rounded-lg border border-white/10 text-gray-400 hover:text-white md:hidden"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
{/* 모바일 오버레이 */}
{isMobileOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setIsMobileOpen(false)}
/>
)}
{/* 사이드바 */}
<div className={`
fixed md:relative h-screen flex flex-col bg-[#121a1d] border-r border-white/5 transition-all duration-300 z-50
${isCollapsed ? 'w-16 sm:w-20' : 'w-56 sm:w-64'}
${isMobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
`}>
<div className={`p-4 sm:p-6 flex items-center justify-between ${isCollapsed ? 'flex-col gap-4' : ''}`}>
{!isCollapsed && <span className="font-serif-logo italic text-xl sm:text-2xl font-bold tracking-tight text-white">CASTAD</span>}
<button
onClick={() => {
if (window.innerWidth < 768) {
setIsMobileOpen(false);
} else {
setIsCollapsed(!isCollapsed);
}
}}
className="p-1 text-gray-400 hover:text-white"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<div className="flex-1 px-3 sm:px-4 mt-2 sm:mt-4 overflow-y-auto no-scrollbar">
{menuItems.map(item => (
<SidebarItem
key={item.id}
icon={item.icon}
label={item.label}
isCollapsed={isCollapsed}
isActive={activeItem === item.id}
onClick={() => handleNavigate(item.id)}
/>
))}
</div>
<div className="p-3 sm:p-4 mt-auto space-y-3 sm:space-y-4">
{!isCollapsed && (
<div className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl p-3 sm:p-4 border border-white/5">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-400 text-[9px] sm:text-[10px] font-medium">Credit</span>
<span className="text-white text-[9px] sm:text-[10px] font-bold">850 / 1000</span>
</div>
<div className="w-full h-1 sm:h-1.5 bg-gray-800 rounded-full overflow-hidden mb-2 sm:mb-3">
<div className="h-full bg-[#a6ffea]" style={{ width: '85%' }}></div>
</div>
<button className="w-full py-1.5 sm:py-2 bg-[#121a1d] text-white text-[9px] sm:text-[10px] font-bold rounded-lg border border-gray-700 hover:bg-gray-800 transition-colors">
</button>
</div>
)}
<div className={`flex items-center gap-2 sm:gap-3 px-2 ${isCollapsed ? 'flex-col' : ''}`}>
<img
src="https://picsum.photos/seed/user/100/100"
alt="Profile"
className="w-8 h-8 sm:w-10 sm:h-10 rounded-full border border-gray-700"
/>
{!isCollapsed && (
<div className="flex-1 min-w-0">
<p className="text-white text-[10px] sm:text-xs font-bold truncate">username1234</p>
</div>
)}
</div>
<button className={`w-full flex items-center gap-2 sm:gap-3 px-3 py-2 sm:py-3 text-gray-400 hover:text-white transition-colors ${isCollapsed ? 'justify-center' : ''}`}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
{!isCollapsed && <span className="text-[10px] sm:text-xs font-bold"></span>}
</button>
</div>
</div>
</>
);
};
export default Sidebar;

16
src/index.tsx Executable file
View File

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,131 @@
import React from 'react';
import { CrawlingResponse } from '../../types/api';
interface AnalysisResultSectionProps {
onBack: () => void;
onGenerate?: () => void;
data: CrawlingResponse;
}
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
const { processed_info, marketing_analysis, image_list } = data;
const tags = marketing_analysis.tags || [];
const facilities = marketing_analysis.facilities || [];
return (
<div className="w-full h-[100dvh] text-white flex flex-col p-3 py-2 sm:p-4 sm:py-3 md:p-6 lg:p-8 overflow-hidden bg-[#121a1d]">
{/* 뒤로가기 버튼 */}
<div className="w-full flex justify-start mb-2 sm:mb-3 shrink-0">
<button
onClick={onBack}
className="flex items-center gap-1.5 py-1 px-3 sm:py-1.5 sm:px-4 rounded-full border border-gray-700 hover:bg-gray-800 transition-colors text-[9px] sm:text-[10px] md:text-xs text-gray-300"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
</div>
{/* 헤더 - 높이가 작을 때 숨김 */}
<div className="hidden sm:flex flex-col items-center text-center mb-2 md:mb-4 shrink-0">
<div className="text-[#a682ff] mb-1 animate-bounce">
<svg className="w-4 h-4 md:w-5 md:h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l2.4 7.2L22 12l-7.6 2.4L12 22l-2.4-7.2L2 12l7.6-2.4z" />
</svg>
</div>
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5"> </h1>
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light max-w-lg px-4">
, .
</p>
</div>
{/* 메인 콘텐츠 그리드 */}
<div className="max-w-6xl mx-auto w-full grid grid-cols-1 md:grid-cols-2 gap-2 sm:gap-3 md:gap-4 flex-1 min-h-0 overflow-hidden">
{/* 브랜드 정체성 */}
<div className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 flex flex-col border border-white/5 shadow-xl overflow-hidden">
<span className="text-[#a6ffea] text-[8px] sm:text-[9px] md:text-[10px] font-bold uppercase tracking-wider mb-1.5 sm:mb-2 block shrink-0"> </span>
<h2 className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold mb-0.5 shrink-0">{processed_info.customer_name}</h2>
<p className="text-gray-500 text-[10px] sm:text-xs md:text-sm mb-2 sm:mb-3 shrink-0">{processed_info.region} · {processed_info.detail_region_info}</p>
{/* 이미지 미리보기 */}
{image_list.length > 0 && (
<div className="mt-auto min-h-0 overflow-hidden">
<span className="text-gray-500 text-[8px] sm:text-[9px] md:text-[10px] font-bold uppercase tracking-wider mb-1.5 block shrink-0"> ({image_list.length})</span>
<div className="grid grid-cols-4 gap-1 sm:gap-1.5">
{image_list.slice(0, 8).map((img, idx) => (
<div key={idx} className="aspect-square rounded sm:rounded-md overflow-hidden bg-[#121a1d]">
<img src={img} alt={`이미지 ${idx + 1}`} className="w-full h-full object-cover" />
</div>
))}
</div>
{image_list.length > 8 && (
<p className="text-gray-500 text-[9px] sm:text-[10px] mt-1 text-center">+{image_list.length - 8} </p>
)}
</div>
)}
</div>
{/* 오른쪽 카드들 */}
<div className="flex flex-col gap-2 sm:gap-3 overflow-hidden">
{/* 시설 정보 */}
<div className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-xl shrink-0">
<span className="text-[#a682ff] text-[8px] sm:text-[9px] md:text-[10px] font-bold uppercase tracking-wider mb-1.5 sm:mb-2 block"> </span>
<div className="flex flex-wrap gap-1 sm:gap-1.5">
{facilities.slice(0, 6).map((facility, idx) => (
<span key={idx} className="px-2 sm:px-2.5 py-1 sm:py-1.5 bg-[#121a1d]/40 rounded-full text-[9px] sm:text-[10px] text-gray-400 border border-white/5">
{facility}
</span>
))}
{facilities.length > 6 && (
<span className="px-2 sm:px-2.5 py-1 sm:py-1.5 bg-[#121a1d]/40 rounded-full text-[9px] sm:text-[10px] text-gray-500 border border-white/5">
+{facilities.length - 6}
</span>
)}
</div>
</div>
{/* 추천 태그 */}
<div className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-xl shrink-0">
<span className="text-[#a6ffea] text-[8px] sm:text-[9px] md:text-[10px] font-bold uppercase tracking-wider mb-1.5 sm:mb-2 block"> </span>
<div className="flex flex-wrap gap-1 sm:gap-1.5">
{tags.slice(0, 6).map((tag, idx) => (
<span key={idx} className="px-2 sm:px-2.5 py-1 sm:py-1.5 bg-[#121a1d]/40 rounded-full text-[9px] sm:text-[10px] text-[#a6ffea]/80 border border-[#a6ffea]/10">
{tag}
</span>
))}
{tags.length > 6 && (
<span className="px-2 sm:px-2.5 py-1 sm:py-1.5 bg-[#121a1d]/40 rounded-full text-[9px] sm:text-[10px] text-[#a6ffea]/50 border border-[#a6ffea]/10">
+{tags.length - 6}
</span>
)}
</div>
</div>
{/* 마케팅 분석 요약 */}
<div className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-xl flex-1 min-h-0 overflow-hidden flex flex-col">
<span className="text-[#a682ff] text-[8px] sm:text-[9px] md:text-[10px] font-bold uppercase tracking-wider mb-1.5 sm:mb-2 block shrink-0"> </span>
<div className="text-gray-400 text-[9px] sm:text-[10px] leading-relaxed overflow-y-auto custom-scrollbar flex-1 min-h-0">
{marketing_analysis.report.split('\n').slice(0, 3).map((line, idx) => (
<p key={idx} className="mb-1 sm:mb-1.5">{line.replace(/^#+\s*/, '')}</p>
))}
</div>
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="mt-2 sm:mt-3 md:mt-4 flex justify-center shrink-0 py-2 sm:py-3 md:py-4">
<button
onClick={onGenerate}
className="bg-[#a682ff] hover:bg-[#9570f0] text-white font-bold py-2 sm:py-2.5 md:py-3 px-6 sm:px-10 md:px-14 rounded-full transition-all transform active:scale-95 shadow-lg shadow-[#a682ff33] text-[9px] sm:text-[10px] md:text-xs"
>
</button>
</div>
</div>
);
};
export default AnalysisResultSection;

View File

@ -0,0 +1,26 @@
import React from 'react';
const LoadingSection: React.FC = () => {
return (
<div className="w-full h-screen bg-[#121a1d] flex flex-col items-center justify-center text-white px-6">
<div className="relative mb-8">
{/* Spinning Outer Ring */}
<div className="w-16 h-16 border-4 border-[#a682ff]/20 border-t-[#a682ff] rounded-full animate-spin"></div>
{/* Pulsing center icon */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-4 h-4 bg-[#a682ff] rounded-full animate-pulse shadow-[0_0_15px_#a682ff]"></div>
</div>
</div>
<div className="text-center space-y-2">
<h2 className="text-xl sm:text-2xl font-bold tracking-tight"> </h2>
<p className="text-gray-400 text-sm sm:text-base font-light animate-pulse">
AI ...
</p>
</div>
</div>
);
};
export default LoadingSection;

View File

@ -0,0 +1,200 @@
import React, { useRef } from 'react';
import { ImageItem } from '../../types/api';
interface AssetManagementContentProps {
onBack: () => void;
onNext: () => void;
imageList: ImageItem[];
onRemoveImage: (index: number) => void;
onAddImages: (files: File[]) => void;
}
const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
onBack,
onNext,
imageList,
onRemoveImage,
onAddImages,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const imageListRef = useRef<HTMLDivElement>(null);
// 이미지 아이템의 표시용 URL 가져오기
const getImageSrc = (item: ImageItem): string => {
return item.type === 'url' ? item.url : item.preview;
};
const handleImageListWheel = (e: React.WheelEvent) => {
const el = imageListRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const hasScrollableContent = scrollHeight > clientHeight;
if (!hasScrollableContent) return;
const atTop = scrollTop <= 0;
const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
// 스크롤 가능한 영역 내에서 스크롤 중이면 이벤트 전파 중지
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
e.stopPropagation();
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files).filter(file =>
file.type.startsWith('image/')
);
if (files.length > 0) {
onAddImages(files);
}
};
const handleFileSelect = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onAddImages(Array.from(files));
// input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = '';
}
};
return (
<main className="flex flex-col h-full p-3 sm:p-4 md:p-6 overflow-hidden">
<div className="flex justify-start mb-2 sm:mb-3 ml-10 md:ml-0 shrink-0">
<button
onClick={onBack}
className="flex items-center gap-1.5 py-1 px-3 sm:py-1.5 sm:px-4 rounded-full border border-gray-700 hover:bg-gray-800 transition-colors text-[9px] sm:text-[10px] md:text-xs text-gray-300"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
</div>
<div className="hidden sm:block text-center mb-2 md:mb-4 shrink-0">
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5 tracking-tight"> </h1>
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light">
, .
</p>
</div>
<div className="flex-1 flex flex-col md:flex-row gap-2 sm:gap-3 md:gap-4 max-w-6xl mx-auto w-full min-h-0 overflow-hidden">
{/* 선택된 이미지 */}
<div className="flex-[2] bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl overflow-hidden flex flex-col">
<div className="flex justify-between items-center mb-2 sm:mb-3 shrink-0">
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold tracking-wide"> </h3>
<span className="text-gray-500 text-[8px] sm:text-[9px] md:text-[10px]">{imageList.length}</span>
</div>
<div
ref={imageListRef}
onWheel={handleImageListWheel}
className="overflow-y-auto flex-1 min-h-0 custom-scrollbar"
style={{ overscrollBehavior: 'contain' }}
>
{imageList.length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-1.5 sm:gap-2">
{imageList.map((item, i) => (
<div
key={i}
className="aspect-square bg-[#121a1d]/50 rounded-lg border border-white/5 relative overflow-hidden group"
>
<img
src={getImageSrc(item)}
alt={`이미지 ${i + 1}`}
className="w-full h-full object-cover"
/>
{/* 업로드된 파일 표시 배지 */}
{item.type === 'file' && (
<div className="absolute top-1 left-1 px-1 py-0.5 bg-[#a682ff]/80 rounded text-[7px] sm:text-[8px] text-white font-medium">
NEW
</div>
)}
<button
onClick={() => onRemoveImage(i)}
className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 bg-black/60 hover:bg-red-500/80 rounded flex items-center justify-center text-white/70 hover:text-white transition-colors opacity-0 group-hover:opacity-100"
>
<svg className="w-2.5 h-2.5 sm:w-3 sm:h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center text-gray-500 text-[9px] sm:text-[10px] py-6 sm:py-10 h-full">
</div>
)}
</div>
</div>
{/* 이미지 업로드 */}
<div className="flex-1 bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl flex flex-col min-h-[120px] sm:min-h-[150px]">
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0"> </h3>
<div
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDrop={handleDrop}
className="flex-1 border-2 border-dashed border-gray-700 rounded-xl sm:rounded-2xl flex flex-col items-center justify-center p-3 sm:p-4 text-center group cursor-pointer hover:border-[#a6ffea]/50 transition-colors"
>
<div className="mb-2 text-gray-500 group-hover:text-[#a6ffea]">
<svg className="w-6 h-6 sm:w-8 sm:h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<p className="text-gray-400 text-[8px] sm:text-[9px] md:text-[10px] font-medium leading-relaxed">
<br/>
</p>
<p className="text-gray-600 text-[7px] sm:text-[8px] mt-1">
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
<div className="flex justify-center py-2 sm:py-3 md:py-4 shrink-0">
<button
onClick={onNext}
disabled={imageList.length === 0}
className={`font-bold py-2 sm:py-2.5 md:py-3 px-6 sm:px-10 md:px-14 rounded-full transition-all transform active:scale-95 shadow-2xl text-[9px] sm:text-[10px] md:text-xs tracking-wide ${
imageList.length > 0
? 'bg-[#a682ff] hover:bg-[#9570f0] text-white shadow-[#a682ff44]'
: 'bg-gray-600 text-gray-400 cursor-not-allowed shadow-none'
}`}
>
</button>
</div>
</main>
);
};
export default AssetManagementContent;

View File

@ -0,0 +1,21 @@
import React from 'react';
import Sidebar from '../../components/Sidebar';
import AssetManagementContent from './AssetManagementContent';
interface AssetManagementSectionProps {
onBack: () => void;
}
const AssetManagementSection: React.FC<AssetManagementSectionProps> = ({ onBack }) => {
return (
<div className="flex w-full h-screen bg-[#0d1416] text-white overflow-hidden">
<Sidebar />
<div className="flex-1 overflow-y-auto">
<AssetManagementContent onBack={onBack} onNext={() => {}} />
</div>
</div>
);
};
export default AssetManagementSection;

View File

@ -0,0 +1,48 @@
import React from 'react';
const SocialItem: React.FC<{ platform: string; icon: React.ReactNode; color: string }> = ({ platform, icon, color }) => (
<div className="bg-[#121a1d] p-2 sm:p-3 rounded-lg sm:rounded-xl border border-white/5 flex items-center justify-between group hover:border-white/10 transition-all">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: color }}>
<div className="w-3 h-3 sm:w-4 sm:h-4">{icon}</div>
</div>
<span className="text-[10px] sm:text-xs font-bold text-gray-300">{platform}</span>
</div>
<button className="text-[#a6ffea] text-[9px] sm:text-[10px] font-bold hover:underline"></button>
</div>
);
const BusinessSettingsContent: React.FC = () => {
return (
<div className="w-full h-full flex flex-col items-center justify-center p-3 sm:p-4 md:p-6 bg-[#121a1d] text-white overflow-hidden">
<div className="text-center mb-4 sm:mb-6 md:mb-8 max-w-2xl ml-10 md:ml-0">
<h1 className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold mb-1 sm:mb-2"> </h1>
<p className="text-gray-400 text-[9px] sm:text-[10px] md:text-xs font-light leading-relaxed opacity-80">
YouTube
</p>
</div>
<div className="w-full max-w-xl">
<div className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-6 border border-white/5 shadow-2xl">
<h2 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 md:mb-4 tracking-widest uppercase"></h2>
<div className="space-y-2 sm:space-y-3">
<SocialItem
platform="Youtube"
color="#ff0000"
icon={<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 4-8 4z"/></svg>}
/>
<SocialItem
platform="Instagram"
color="#e4405f"
icon={<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259 0 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.791-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.209-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>}
/>
</div>
</div>
</div>
</div>
);
};
export default BusinessSettingsContent;

View File

@ -0,0 +1,131 @@
import React, { useState } from 'react';
interface CompletionContentProps {
onBack: () => void;
}
const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
const [selectedSocials, setSelectedSocials] = useState<string[]>([]);
const toggleSocial = (id: string) => {
setSelectedSocials(prev =>
prev.includes(id)
? prev.filter(s => s !== id)
: [...prev, id]
);
};
const socials = [
{ id: 'Youtube', color: '#ff0000', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 4-8 4z"/></svg> },
{ id: 'Instagram', color: '#e4405f', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259 0 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.791-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.209-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg> },
{ id: 'Facebook', color: '#1877f2', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg> }
];
return (
<main className="flex flex-col h-full p-3 sm:p-4 md:p-6 overflow-hidden">
<div className="flex justify-start mb-2 sm:mb-3 ml-10 md:ml-0 shrink-0">
<button
onClick={onBack}
className="flex items-center gap-1.5 py-1 px-3 sm:py-1.5 sm:px-4 rounded-full border border-gray-700 hover:bg-gray-800 transition-colors text-[9px] sm:text-[10px] md:text-xs text-gray-300"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
</div>
<div className="hidden sm:block text-center mb-2 md:mb-4 shrink-0">
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5 tracking-tight"> </h1>
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light">
.
</p>
</div>
<div className="flex-1 flex flex-col md:flex-row gap-2 sm:gap-3 md:gap-4 max-w-6xl mx-auto w-full min-h-0 overflow-hidden">
{/* Left: Video Preview */}
<div className="flex-[2] bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl flex flex-col overflow-hidden">
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0"> </h3>
<div className="flex-1 bg-black rounded-xl sm:rounded-2xl relative overflow-hidden flex items-center justify-center group min-h-[100px]">
<div className="absolute inset-0 opacity-[0.1]" style={{ backgroundImage: 'linear-gradient(45deg, #121a1d 25%, transparent 25%), linear-gradient(-45deg, #121a1d 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #121a1d 75%), linear-gradient(-45deg, transparent 75%, #121a1d 75%)', backgroundSize: '40px 40px', backgroundPosition: '0 0, 0 20px, 20px -20px, -20px 0px' }}></div>
<div className="absolute bottom-2 sm:bottom-3 left-2 sm:left-3 right-2 sm:right-3 z-10">
<div className="w-full h-1 bg-white/20 rounded-full overflow-hidden">
<div className="w-[40%] h-full bg-[#a6ffea]"></div>
</div>
<div className="flex items-center gap-2 sm:gap-3 mt-2">
<button className="text-white hover:text-[#a6ffea]">
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
</div>
</div>
</div>
<div className="mt-2 sm:mt-3 bg-[#121a1d]/60 rounded-lg sm:rounded-xl py-2 px-3 flex flex-wrap gap-1.5 sm:gap-2 justify-center border border-white/5 shrink-0">
{['AI 최적화', '색상 보정', '자막', '비트 싱크', 'SEO'].map(tag => (
<span key={tag} className="flex items-center gap-1 text-[8px] sm:text-[9px] text-gray-400">
<span className="w-1 h-1 rounded-full bg-[#a6ffea]"></span>
{tag}
</span>
))}
</div>
</div>
{/* Right: Sharing */}
<div className="flex-1 bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl flex flex-col overflow-hidden">
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0"> </h3>
<div className="space-y-2 sm:space-y-3 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
{socials.map(social => {
const isSelected = selectedSocials.includes(social.id);
return (
<div
key={social.id}
onClick={() => toggleSocial(social.id)}
className={`flex items-center gap-2 sm:gap-3 bg-[#121a1d] p-2 sm:p-2.5 rounded-lg sm:rounded-xl border transition-all cursor-pointer ${
isSelected
? 'border-[#a6ffea] shadow-[0_0_15px_rgba(166,255,234,0.15)] bg-[#1c2a2e]'
: 'border-white/5 hover:border-white/10 grayscale hover:grayscale-0 opacity-60 hover:opacity-100'
}`}
>
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-lg flex items-center justify-center relative transition-transform duration-300 group-hover:scale-105" style={{ backgroundColor: social.color }}>
<div className="w-3 h-3 sm:w-4 sm:h-4">{social.icon}</div>
{isSelected && (
<div className="absolute -top-1 -right-1 w-3 h-3 sm:w-4 sm:h-4 bg-[#a6ffea] rounded-full flex items-center justify-center text-[#121a1d] border border-[#121a1d]">
<svg className="w-2 h-2 sm:w-2.5 sm:h-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4"><polyline points="20 6 9 17 4 12"/></svg>
</div>
)}
</div>
<span className={`text-[10px] sm:text-xs font-bold tracking-tight transition-colors ${isSelected ? 'text-[#a6ffea]' : 'text-gray-500'}`}>
{social.id}
</span>
</div>
);
})}
</div>
<button
disabled={selectedSocials.length === 0}
className={`mt-2 sm:mt-3 w-full py-2 sm:py-2.5 font-bold rounded-lg sm:rounded-xl transition-all transform active:scale-95 text-[9px] sm:text-[10px] md:text-xs shadow-lg shrink-0 ${
selectedSocials.length > 0
? 'bg-[#a682ff] text-white shadow-[#a682ff33] hover:bg-[#9570f0]'
: 'bg-gray-700 text-gray-500 cursor-not-allowed opacity-50'
}`}
>
{selectedSocials.length > 0 ? `${selectedSocials.length}개 채널에 배포` : '배포할 채널을 선택하세요'}
</button>
</div>
</div>
<div className="flex justify-center gap-3 py-2 sm:py-3 md:py-4 shrink-0">
<button className="bg-transparent border border-gray-700 hover:border-gray-500 text-white font-bold py-2 sm:py-2.5 px-6 sm:px-10 rounded-full transition-all text-[9px] sm:text-[10px]">
MP4
</button>
</div>
</main>
);
};
export default CompletionContent;

View File

@ -0,0 +1,107 @@
import React from 'react';
const StatCard: React.FC<{ label: string; value: string; trend: string }> = ({ label, value, trend }) => (
<div className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl p-2 sm:p-3 md:p-4 border border-white/5 flex flex-col justify-center gap-0.5 shadow-lg transition-transform hover:scale-[1.02]">
<span className="text-[8px] sm:text-[9px] md:text-[10px] font-bold text-gray-400 uppercase tracking-widest">{label}</span>
<h3 className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold text-white leading-tight">{value}</h3>
<span className="text-[8px] sm:text-[9px] text-[#a6ffea] font-medium">{trend}</span>
</div>
);
const DashboardContent: React.FC = () => {
return (
<div className="w-full h-full p-3 sm:p-4 md:p-6 flex flex-col bg-[#121a1d] text-white overflow-hidden">
<div className="shrink-0 mb-2 sm:mb-3 md:mb-4 ml-10 md:ml-0">
<h1 className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight"></h1>
<p className="text-[9px] sm:text-[10px] text-gray-500 mt-0.5"> .</p>
</div>
{/* Top Stats Grid - Fixed height based on content */}
<div className="shrink-0 grid grid-cols-3 gap-2 sm:gap-3 mb-2 sm:mb-3 md:mb-4">
<StatCard label="TOTAL REACH" value="124.5k" trend="지난 주 12%" />
<StatCard label="CONVERSIONS" value="3,892" trend="지난 주 8%" />
<StatCard label="SEO SCORE" value="98/100" trend="지난 주 12%" />
</div>
{/* Engagement Chart Section - Flex-1 to fill remaining space */}
<div className="flex-1 min-h-0 bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-6 border border-white/5 flex flex-col relative shadow-2xl overflow-hidden">
<div className="shrink-0 flex justify-between items-center mb-2 sm:mb-3 md:mb-4">
<h2 className="text-[8px] sm:text-[9px] md:text-[10px] font-bold text-gray-400 uppercase tracking-widest">Engagement Overview</h2>
<div className="flex gap-1.5 items-center">
<span className="w-1.5 h-1.5 rounded-full bg-[#a6ffea]"></span>
<span className="text-[8px] sm:text-[9px] text-gray-400">Weekly Active</span>
</div>
</div>
<div className="flex-1 relative min-h-0">
{/* Chart SVG - Responsive viewBox and preserveAspectRatio */}
<svg
className="w-full h-full overflow-visible"
viewBox="0 0 1000 400"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#a6ffea" stopOpacity="0.2" />
<stop offset="100%" stopColor="#a6ffea" stopOpacity="0" />
</linearGradient>
</defs>
{/* Grid Lines */}
{[0, 100, 200, 300, 400].map(y => (
<line key={y} x1="0" y1={y} x2="1000" y2={y} stroke="white" strokeOpacity="0.03" strokeWidth="1" />
))}
{/* Smooth Line Path */}
<path
d="M0,320 C100,340 200,220 300,260 C400,300 450,140 550,180 C650,220 750,340 850,280 C950,220 1000,80 1000,80"
fill="none"
stroke="#a6ffea"
strokeWidth="3"
strokeLinecap="round"
/>
{/* Gradient Fill */}
<path
d="M0,320 C100,340 200,220 300,260 C400,300 450,140 550,180 C650,220 750,340 850,280 C950,220 1000,80 1000,80 V400 H0 Z"
fill="url(#chartGradient)"
/>
{/* Pulsing Dot at X=550, Y=180 */}
<g transform="translate(550, 180)">
<circle r="5" fill="#a6ffea">
<animate attributeName="r" from="5" to="15" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" from="0.6" to="0" dur="2s" repeatCount="indefinite" />
</circle>
<circle r="6" fill="#a6ffea" className="drop-shadow-[0_0_12px_rgba(166,255,234,0.8)]" />
</g>
</svg>
{/* Floating Data Badge - Positioning relative to SVG percentage */}
<div
className="absolute top-[45%] left-[55%] -translate-x-1/2 -translate-y-full mb-2 flex flex-col items-center pointer-events-none"
>
<div className="bg-[#a6ffea] text-[#121a1d] px-2 py-0.5 rounded-md text-[9px] sm:text-[10px] font-bold shadow-xl shadow-[#a6ffea33]">
1,234
</div>
<div className="w-0.5 h-2 sm:h-3 bg-[#a6ffea]/50 mt-0.5"></div>
</div>
</div>
{/* X Axis Labels - Fixed bottom */}
<div className="shrink-0 flex justify-between mt-2 sm:mt-3 md:mt-4 px-2 sm:px-4 md:px-6 text-[8px] sm:text-[9px] text-gray-500 font-bold uppercase tracking-widest border-t border-white/5 pt-2 sm:pt-3">
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
<span>Sun</span>
</div>
</div>
</div>
);
};
export default DashboardContent;

View File

@ -0,0 +1,161 @@
import React, { useRef, useEffect, useState } from 'react';
import Sidebar from '../../components/Sidebar';
import AssetManagementContent from './AssetManagementContent';
import SoundStudioContent from './SoundStudioContent';
import CompletionContent from './CompletionContent';
import DashboardContent from './DashboardContent';
import BusinessSettingsContent from './BusinessSettingsContent';
import { ImageItem } from '../../types/api';
interface BusinessInfo {
customer_name: string;
region: string;
detail_region_info: string;
}
interface GenerationFlowProps {
onHome: () => void;
initialActiveItem?: string;
initialImageList?: string[];
businessInfo?: BusinessInfo;
}
const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveItem = '대시보드', initialImageList = [], businessInfo }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [activeItem, setActiveItem] = useState(initialActiveItem);
const [maxWizardIndex, setMaxWizardIndex] = useState(0);
// URL 이미지를 ImageItem 형태로 변환하여 초기화
const [imageList, setImageList] = useState<ImageItem[]>(
initialImageList.map(url => ({ type: 'url', url }))
);
const handleRemoveImage = (index: number) => {
setImageList(prev => {
const item = prev[index];
// 파일 이미지인 경우 메모리 해제
if (item.type === 'file') {
URL.revokeObjectURL(item.preview);
}
return prev.filter((_, i) => i !== index);
});
};
const handleAddImages = (files: File[]) => {
const newImages: ImageItem[] = files.map(file => ({
type: 'file',
file,
preview: URL.createObjectURL(file),
}));
setImageList(prev => [...prev, ...newImages]);
};
const scrollToWizardSection = (index: number) => {
if (scrollContainerRef.current) {
const sections = scrollContainerRef.current.querySelectorAll('.flow-section');
if (sections[index]) {
setMaxWizardIndex(prev => Math.max(prev, index));
sections[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
};
useEffect(() => {
const container = scrollContainerRef.current;
if (!container || activeItem !== '새 프로젝트 만들기') return;
const handleWheel = (e: WheelEvent) => {
// 스크롤 가능한 자식 요소 내부에서 발생한 이벤트인지 확인
const target = e.target as HTMLElement;
// custom-scrollbar 클래스를 가진 요소 또는 그 자식에서 발생한 이벤트인지 확인
const scrollableParent = target.closest('.custom-scrollbar') as HTMLElement;
if (scrollableParent) {
const { scrollTop, scrollHeight, clientHeight } = scrollableParent;
const hasScrollableContent = scrollHeight > clientHeight;
const atTop = scrollTop <= 0;
const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
// 스크롤 가능한 콘텐츠가 있는 경우
if (hasScrollableContent) {
// 위로 스크롤하고 맨 위가 아니거나, 아래로 스크롤하고 맨 아래가 아니면 허용
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
return; // 이미지 리스트 스크롤 허용 (preventDefault 하지 않음)
}
}
}
const h = container.clientHeight;
const currentIdx = Math.round(container.scrollTop / h);
if (e.deltaY > 0) { // Down
if (currentIdx >= maxWizardIndex) {
e.preventDefault();
}
}
};
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}, [maxWizardIndex, activeItem]);
const renderContent = () => {
switch (activeItem) {
case '대시보드':
return <DashboardContent />;
case '비즈니스 설정':
return <BusinessSettingsContent />;
case '새 프로젝트 만들기':
return (
<div
ref={scrollContainerRef}
className="flex-1 h-full overflow-y-auto snap-y snap-mandatory scroll-smooth no-scrollbar"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{/* Step 0: Asset Management (AnalysisResultSection removed) */}
<div className="flow-section snap-start h-full w-full flex flex-col overflow-hidden">
<AssetManagementContent
onBack={() => setActiveItem('대시보드')}
onNext={() => scrollToWizardSection(1)}
imageList={imageList}
onRemoveImage={handleRemoveImage}
onAddImages={handleAddImages}
/>
</div>
{/* Step 1: Sound Studio */}
<div className="flow-section snap-start h-full w-full flex flex-col">
<SoundStudioContent
onBack={() => scrollToWizardSection(0)}
onNext={() => scrollToWizardSection(2)}
businessInfo={businessInfo}
/>
</div>
{/* Step 2: Completion */}
<div className="flow-section snap-start h-full w-full flex flex-col overflow-hidden">
<CompletionContent
onBack={() => scrollToWizardSection(1)}
/>
</div>
</div>
);
default:
return (
<div className="flex-1 flex items-center justify-center text-gray-500 font-light">
{activeItem} .
</div>
);
}
};
return (
<div className="flex w-full h-[100dvh] bg-[#0d1416] text-white overflow-hidden">
<Sidebar activeItem={activeItem} onNavigate={setActiveItem} />
<div className="flex-1 h-full relative overflow-hidden pl-0 md:pl-0">
{renderContent()}
</div>
</div>
);
};
export default GenerationFlow;

View File

@ -0,0 +1,605 @@
import React, { useState, useRef, useEffect } from 'react';
import { generateLyric, generateSong, waitForSongComplete } from '../../utils/api';
import { LANGUAGE_MAP } from '../../types/api';
interface BusinessInfo {
customer_name: string;
region: string;
detail_region_info: string;
}
interface SoundStudioContentProps {
onBack: () => void;
onNext: () => void;
businessInfo?: BusinessInfo;
}
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
// localStorage에 저장할 데이터 구조
interface SavedGenerationState {
taskId: string;
lyrics: string;
status: GenerationStatus;
timestamp: number;
}
const STORAGE_KEY = 'castad_song_generation';
const STORAGE_EXPIRY = 30 * 60 * 1000; // 30분
const MAX_RETRY_COUNT = 3; // 최대 재시도 횟수
const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext, businessInfo }) => {
const [selectedType, setSelectedType] = useState('보컬');
const [selectedLang, setSelectedLang] = useState('한국어');
const [selectedGenre, setSelectedGenre] = useState('AI 추천');
const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState<GenerationStatus>('idle');
const [showLyrics, setShowLyrics] = useState(false);
const [lyrics, setLyrics] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [retryCount, setRetryCount] = useState(0);
const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// localStorage에 상태 저장
const saveToStorage = (taskId: string, currentLyrics: string, currentStatus: GenerationStatus) => {
const data: SavedGenerationState = {
taskId,
lyrics: currentLyrics,
status: currentStatus,
timestamp: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
};
// localStorage에서 상태 제거
const clearStorage = () => {
localStorage.removeItem(STORAGE_KEY);
};
// localStorage에서 상태 복구
const loadFromStorage = (): SavedGenerationState | null => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return null;
const data: SavedGenerationState = JSON.parse(saved);
// 만료된 데이터인지 확인 (30분)
if (Date.now() - data.timestamp > STORAGE_EXPIRY) {
clearStorage();
return null;
}
return data;
} catch {
clearStorage();
return null;
}
};
// 컴포넌트 마운트 시 저장된 상태 복구
useEffect(() => {
const savedState = loadFromStorage();
if (savedState && (savedState.status === 'polling' || savedState.status === 'generating_song')) {
// 저장된 가사가 있으면 표시
if (savedState.lyrics) {
setLyrics(savedState.lyrics);
setShowLyrics(true);
}
// 폴링 상태 복구
setStatus('polling');
setStatusMessage('음악을 처리하고 있습니다... (새로고침 후 복구됨)');
// 폴링 재개 (저장된 가사와 함께)
resumePolling(savedState.taskId, savedState.lyrics, 0);
}
}, []);
// 폴링 재개 함수 (타임아웃 시 재생성)
const resumePolling = async (taskId: string, currentLyrics: string, currentRetryCount: number = 0) => {
try {
const downloadResponse = await waitForSongComplete(
taskId,
(pollStatus) => {
if (pollStatus === 'pending') {
setStatusMessage('대기 중...');
} else if (pollStatus === 'processing') {
setStatusMessage('음악을 처리하고 있습니다...');
}
}
);
if (!downloadResponse.success) {
throw new Error(downloadResponse.error_message || '음악 다운로드에 실패했습니다.');
}
setAudioUrl(downloadResponse.file_url);
setStatus('complete');
setStatusMessage('');
setRetryCount(0);
clearStorage();
} catch (error) {
console.error('Polling failed:', error);
// 타임아웃인 경우 재생성 시도
if (error instanceof Error && error.message === 'TIMEOUT') {
if (currentRetryCount < MAX_RETRY_COUNT) {
const newRetryCount = currentRetryCount + 1;
setRetryCount(newRetryCount);
setStatusMessage(`시간 초과로 재생성 중... (${newRetryCount}/${MAX_RETRY_COUNT})`);
// 새로운 노래 생성 요청
await regenerateSongOnly(currentLyrics, newRetryCount);
} else {
setStatus('error');
setErrorMessage('여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.');
setRetryCount(0);
clearStorage();
}
} else {
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : '음악 생성 중 오류가 발생했습니다.');
setRetryCount(0);
clearStorage();
}
}
};
// 노래만 재생성 (가사는 유지)
const regenerateSongOnly = async (currentLyrics: string, currentRetryCount: number) => {
if (!businessInfo) return;
try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const genreMap: Record<string, string> = {
'AI 추천': 'pop',
'로파이': 'lofi',
'힙합': 'hip-hop',
'어쿠스틱': 'acoustic',
};
const songResponse = await generateSong({
genre: genreMap[selectedGenre] || 'pop',
language,
lyrics: currentLyrics,
});
if (!songResponse.success) {
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
}
// 새 task_id로 저장
saveToStorage(songResponse.task_id, currentLyrics, 'polling');
// 폴링 재개
await resumePolling(songResponse.task_id, currentLyrics, currentRetryCount);
} catch (error) {
console.error('Song regeneration failed:', error);
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : '음악 재생성 중 오류가 발생했습니다.');
setRetryCount(0);
clearStorage();
}
};
const handleMove = (clientX: number) => {
if (!progressBarRef.current || !audioRef.current) return;
const rect = progressBarRef.current.getBoundingClientRect();
const newProgress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
const newTime = (newProgress / 100) * duration;
audioRef.current.currentTime = newTime;
setProgress(newProgress);
setCurrentTime(newTime);
};
const onMouseDown = (e: React.MouseEvent) => {
if (!audioUrl) return;
setIsDragging(true);
handleMove(e.clientX);
};
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (isDragging) handleMove(e.clientX);
};
const onMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [isDragging]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleTimeUpdate = () => {
setCurrentTime(audio.currentTime);
if (audio.duration > 0) {
setProgress((audio.currentTime / audio.duration) * 100);
}
};
const handleLoadedMetadata = () => {
setDuration(audio.duration);
};
const handleEnded = () => {
setIsPlaying(false);
setProgress(0);
setCurrentTime(0);
};
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('ended', handleEnded);
return () => {
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('ended', handleEnded);
};
}, [audioUrl]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const togglePlayPause = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
};
const handleGenerateMusic = async () => {
if (!businessInfo) {
setErrorMessage('비즈니스 정보가 없습니다. 다시 시도해주세요.');
return;
}
setStatus('generating_lyric');
setErrorMessage(null);
setStatusMessage('가사를 생성하고 있습니다...');
try {
// Step 1: Generate lyrics
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
let lyricResponse = await generateLyric({
customer_name: businessInfo.customer_name,
detail_region_info: businessInfo.detail_region_info,
language,
region: businessInfo.region,
});
// Retry if the response contains error message
let retryCount = 0;
while (lyricResponse.lyric.includes("I'm sorry") && retryCount < 3) {
retryCount++;
setStatusMessage(`가사 재생성 중... (${retryCount}/3)`);
lyricResponse = await generateLyric({
customer_name: businessInfo.customer_name,
detail_region_info: businessInfo.detail_region_info,
language,
region: businessInfo.region,
});
}
if (!lyricResponse.success) {
throw new Error(lyricResponse.error_message || '가사 생성에 실패했습니다.');
}
// 3번 재시도 후에도 여전히 에러 메시지가 포함되어 있으면 가사 카드를 표시하지 않고 에러 처리
if (lyricResponse.lyric.includes("I'm sorry")) {
throw new Error('가사 생성에 실패했습니다. 다시 시도해주세요.');
}
setLyrics(lyricResponse.lyric);
setShowLyrics(true);
// Step 2: Generate song
setStatus('generating_song');
setStatusMessage('음악을 생성하고 있습니다...');
const genreMap: Record<string, string> = {
'AI 추천': 'pop',
'로파이': 'lofi',
'힙합': 'hip-hop',
'어쿠스틱': 'acoustic',
};
const songResponse = await generateSong({
genre: genreMap[selectedGenre] || 'pop',
language,
lyrics: lyricResponse.lyric,
});
if (!songResponse.success) {
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
}
// Step 3: Poll for completion - 상태 저장
setStatus('polling');
setStatusMessage('음악을 처리하고 있습니다...');
saveToStorage(songResponse.task_id, lyricResponse.lyric, 'polling');
// 폴링 시작 (타임아웃 시 자동 재생성)
await resumePolling(songResponse.task_id, lyricResponse.lyric, 0);
} catch (error) {
console.error('Music generation failed:', error);
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : '음악 생성 중 오류가 발생했습니다.');
setRetryCount(0);
clearStorage();
}
};
const handleRegenerate = async () => {
setShowLyrics(false);
setAudioUrl(null);
setLyrics('');
setProgress(0);
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
setRetryCount(0);
clearStorage();
await handleGenerateMusic();
};
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Hidden audio element */}
{audioUrl && (
<audio ref={audioRef} src={audioUrl} preload="metadata" />
)}
<div className="flex-shrink-0 p-3 sm:p-4 md:p-6 pb-0">
<div className="flex justify-start mb-2 sm:mb-3 ml-10 md:ml-0">
<button
onClick={onBack}
className="flex items-center gap-1.5 py-1 px-3 sm:py-1.5 sm:px-4 rounded-full border border-gray-700 hover:bg-gray-800 transition-colors text-[9px] sm:text-[10px] md:text-xs text-gray-300"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
</div>
<div className="hidden sm:block text-center mb-2 md:mb-4">
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5 tracking-tight"> </h1>
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light">
, .
</p>
</div>
</div>
<div className="flex-1 px-3 sm:px-4 md:px-6 min-h-0 overflow-hidden">
<div className="flex flex-col md:flex-row gap-2 sm:gap-3 md:gap-4 max-w-6xl mx-auto w-full justify-center h-full">
{/* Left Card: Audio Style */}
<div
className={`bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl flex flex-col transition-all duration-700 ease-out overflow-hidden ${
showLyrics ? 'md:flex-[1.5]' : 'flex-1 md:max-w-xl'
}`}
>
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0"> </h3>
<div className="space-y-2 sm:space-y-3 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
<div>
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold">AI </p>
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{['보컬', '성우 내레이션', '배경음악'].map(type => (
<button
key={type}
onClick={() => setSelectedType(type)}
disabled={isGenerating}
className={`py-1.5 sm:py-2 rounded-lg border text-[8px] sm:text-[9px] md:text-[10px] font-bold transition-all ${selectedType === type ? 'border-[#a6ffea] bg-[#121a1d] text-[#a6ffea]' : 'border-gray-800 bg-[#121a1d]/40 text-gray-400'} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{type}
</button>
))}
</div>
</div>
<div>
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold"> </p>
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{[
{ label: '한국어', flag: '🇰🇷' },
{ label: 'English', flag: '🇺🇸' },
{ label: '中文', flag: '🇨🇳' },
{ label: '日本語', flag: '🇯🇵' },
{ label: 'ไทย', flag: '🇹🇭' },
{ label: 'Tiếng Việt', flag: '🇻🇳' }
].map(lang => (
<button
key={lang.label}
onClick={() => setSelectedLang(lang.label)}
disabled={isGenerating}
className={`py-1.5 sm:py-2 rounded-lg border text-[8px] sm:text-[9px] font-bold flex flex-col items-center gap-0.5 transition-all ${selectedLang === lang.label ? 'border-[#a6ffea] bg-[#121a1d] text-[#a6ffea]' : 'border-gray-800 bg-[#121a1d]/40 text-gray-400'} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="text-xs sm:text-sm">{lang.flag}</span>
<span>{lang.label}</span>
</button>
))}
</div>
</div>
<div>
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold"> </p>
<select
value={selectedGenre}
onChange={(e) => setSelectedGenre(e.target.value)}
disabled={isGenerating}
className={`w-full py-1.5 sm:py-2 px-2 sm:px-3 rounded-lg bg-[#121a1d] border border-gray-800 text-[8px] sm:text-[9px] md:text-[10px] text-gray-300 focus:outline-none focus:border-[#a6ffea] ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<option>AI </option>
<option></option>
<option></option>
<option></option>
</select>
</div>
</div>
{/* Error Message */}
{errorMessage && (
<div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-[9px] sm:text-[10px] shrink-0">
{errorMessage}
</div>
)}
{/* Status Message */}
{isGenerating && statusMessage && (
<div className="mt-2 p-2 bg-[#a6ffea]/10 border border-[#a6ffea]/30 rounded-lg text-[#a6ffea] text-[9px] sm:text-[10px] flex items-center gap-2 shrink-0">
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{statusMessage}
</div>
)}
<div className="mt-2 sm:mt-3 shrink-0">
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`w-full py-2 sm:py-2.5 md:py-3 font-bold rounded-xl transition-all transform active:scale-[0.98] text-[10px] sm:text-xs flex items-center justify-center gap-2 ${
isGenerating
? 'bg-[#a6ffea]/50 text-[#121a1d]/50 cursor-not-allowed'
: 'bg-[#a6ffea] text-[#121a1d] hover:bg-[#8affda]'
}`}
>
{isGenerating ? (
<>
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
...
</>
) : (
'음악 생성'
)}
</button>
</div>
</div>
{/* Right Card: Lyrics - 애니메이션으로 나타남 */}
{showLyrics && (
<div
className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl flex flex-col flex-1 animate-slide-in overflow-hidden"
style={{ minWidth: '240px' }}
>
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-0.5 sm:mb-1 tracking-wide shrink-0"></h3>
<p className="text-gray-500 text-[8px] sm:text-[9px] mb-2 sm:mb-3 shrink-0"> </p>
<div className="flex-1 bg-[#121a1d]/50 rounded-lg sm:rounded-xl border border-white/5 p-2 sm:p-3 flex flex-col gap-2 min-h-0 overflow-hidden">
{/* Interactive Player Bar */}
<div className="bg-black/40 rounded-full p-1.5 sm:p-2 flex items-center gap-2 shrink-0">
<button
onClick={togglePlayPause}
disabled={!audioUrl}
className={`text-[#a6ffea] flex-shrink-0 transition-transform hover:scale-110 ${!audioUrl ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isPlaying ? (
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
)}
</button>
<div
ref={progressBarRef}
onMouseDown={onMouseDown}
className={`flex-1 h-1 bg-gray-800 rounded-full relative group ${audioUrl ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`}
>
<div
className="absolute left-0 top-0 h-full bg-[#a6ffea] rounded-full"
style={{ width: `${progress}%` }}
></div>
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex items-center justify-center pointer-events-none"
style={{ left: `${progress}%` }}
>
<div className="absolute w-3 h-3 sm:w-4 sm:h-4 bg-[#a6ffea] rounded-full opacity-20 animate-pulse"></div>
<div className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-[#a6ffea] rounded-full shadow-[0_0_10px_rgba(166,255,234,0.6)] border border-[#121a1d]/20"></div>
</div>
</div>
<span className="text-[8px] sm:text-[9px] text-gray-500 font-mono w-12 sm:w-14 text-right">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* Editable Lyrics */}
<textarea
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
className="flex-1 overflow-y-auto text-[9px] sm:text-[10px] text-gray-400 bg-transparent resize-none focus:outline-none focus:text-gray-300 pr-2 custom-scrollbar leading-relaxed min-h-0"
placeholder="가사가 여기에 표시됩니다..."
/>
</div>
<button
onClick={handleRegenerate}
disabled={isGenerating}
className={`mt-2 sm:mt-3 w-full py-2 sm:py-2.5 bg-[#a6ffea]/20 text-[#a6ffea] border border-[#a6ffea]/30 font-bold rounded-lg sm:rounded-xl hover:bg-[#a6ffea]/30 transition-all text-[9px] sm:text-[10px] md:text-xs shrink-0 ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
</div>
)}
</div>
</div>
<div className="flex-shrink-0 flex justify-center py-2 sm:py-3 md:py-4 px-3 sm:px-4">
<button
onClick={onNext}
disabled={status !== 'complete'}
className={`font-bold py-2 sm:py-2.5 md:py-3 px-6 sm:px-10 md:px-14 rounded-full transition-all transform active:scale-95 shadow-2xl text-[9px] sm:text-[10px] md:text-xs tracking-wide ${
status === 'complete'
? 'bg-[#a682ff] hover:bg-[#9570f0] text-white shadow-[#a682ff44]'
: 'bg-gray-600 text-gray-400 cursor-not-allowed shadow-none'
}`}
>
</button>
</div>
</div>
);
};
export default SoundStudioContent;

View File

@ -0,0 +1,63 @@
import React from 'react';
import Header from '../../components/Header';
interface DisplaySectionProps {
onStartClick?: () => void;
}
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
const videoIds = ['dM8_d6Aud68', 'bb8nKmKcT0c', 'dM8_d6Aud68'];
return (
<div className="w-full h-full flex flex-col bg-[#121a1d] text-white relative overflow-hidden">
<Header />
<div className="content-safe-area pt-24 sm:pt-28">
{/* Main visual frames container */}
<div className="flex flex-row justify-center items-center gap-2 sm:gap-6 md:gap-10 lg:gap-14 mb-8 md:mb-16 w-full max-w-6xl">
{videoIds.map((videoId, index) => (
<div
key={`${videoId}-${index}`}
className={`
${index === 2 ? 'hidden md:flex' : 'flex'}
w-[135px] sm:w-[190px] md:w-[230px] lg:w-[280px]
aspect-[9/16] rounded-[24px] sm:rounded-[40px] md:rounded-[48px]
bg-black border border-white border-opacity-10
overflow-hidden relative shadow-[0_30px_80px_rgba(0,0,0,0.9)]
items-center justify-center transition-all
`}
>
{/* YouTube Embed Container - 9:16 ratio */}
<div className="absolute inset-0 w-full h-full pointer-events-none overflow-hidden">
<iframe
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[180%] h-[100%] object-cover"
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&mute=1&loop=1&playlist=${videoId}&controls=0&showinfo=0&rel=0&modestbranding=1&iv_load_policy=3&disablekb=1&playsinline=1`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
</div>
{/* Device Bezel/Frame */}
<div className="absolute inset-0 border-[6px] sm:border-[10px] border-[#20282b] rounded-[24px] sm:rounded-[40px] md:rounded-[48px] pointer-events-none z-20"></div>
{/* Notch */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-1/4 h-3 sm:h-5 bg-[#20282b] rounded-b-2xl z-30 opacity-80"></div>
</div>
))}
</div>
{/* Action Button */}
<button
onClick={onStartClick}
className="py-3 px-14 sm:py-3.5 sm:px-16 rounded-full bg-[#a682ff] hover:bg-[#9570f0] transition-all transform active:scale-95 text-white font-bold text-xs sm:text-base tracking-wide shadow-xl shadow-[#a682ff44] mb-8"
>
</button>
</div>
</div>
);
};
export default DisplaySection;

105
src/pages/Landing/HeroSection.tsx Executable file
View File

@ -0,0 +1,105 @@
import React, { useState } from 'react';
interface HeroSectionProps {
onAnalyze?: (url: string) => void;
onNext?: () => void;
error?: string | null;
}
const isValidUrl = (string: string): boolean => {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: externalError }) => {
const [url, setUrl] = useState('');
const [localError, setLocalError] = useState('');
const error = externalError || localError;
const handleStart = () => {
if (!url.trim()) {
setLocalError('URL을 입력해주세요.');
return;
}
if (!isValidUrl(url.trim())) {
setLocalError('올바른 URL 형식이 아닙니다. (예: https://example.com)');
return;
}
setLocalError('');
if (onAnalyze) {
onAnalyze(url);
}
};
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-[#121a1d] text-white px-4 relative">
<div className="content-safe-area">
{/* Title */}
<h1 className="font-serif-logo italic text-6xl sm:text-7xl md:text-8xl lg:text-9xl font-bold mb-4 tracking-tight text-center leading-none">
CASTAD
</h1>
{/* Subtitle */}
<p className="text-sm sm:text-base md:text-xl font-light mb-12 opacity-80 text-center max-w-lg">
Marketing Automation for Location Based Business
</p>
{/* Input Form */}
<div className="w-full max-w-sm flex flex-col gap-3">
<input
type="text"
value={url}
onChange={(e) => {
setUrl(e.target.value);
if (localError) setLocalError('');
}}
placeholder="URL 입력"
className={`w-full py-3 sm:py-4 px-6 rounded-full bg-white text-gray-800 text-center focus:outline-none placeholder:text-gray-400 font-medium text-sm sm:text-base ${error ? 'ring-2 ring-red-500' : ''}`}
/>
{error && (
<p className="text-red-400 text-xs text-center">{error}</p>
)}
<button
onClick={handleStart}
className="w-full py-3 sm:py-4 px-6 rounded-full bg-[#a682ff] hover:bg-[#9570f0] transition-all transform active:scale-95 text-white font-bold text-sm sm:text-base shadow-lg shadow-[#a682ff33]"
>
</button>
</div>
</div>
{/* Footer Indicator - Now a button */}
<button
onClick={onNext}
className="absolute bottom-6 sm:bottom-10 flex flex-col items-center gap-2 opacity-40 hover:opacity-100 transition-opacity cursor-pointer animate-bounce"
>
<span className="text-[10px] sm:text-xs font-light"> </span>
<div className="w-8 h-8 sm:w-10 sm:h-10 border border-white rounded-full flex items-center justify-center">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7 13l5 5 5-5M7 6l5 5 5-5" />
</svg>
</div>
</button>
</div>
);
};
export default HeroSection;

View File

@ -0,0 +1,87 @@
import React from 'react';
import Header from '../../components/Header';
interface WelcomeSectionProps {
onStartClick?: () => void;
onNext?: () => void;
}
const WelcomeSection: React.FC<WelcomeSectionProps> = ({ onStartClick, onNext }) => {
const features = [
{
id: 1,
title: '비즈니스 핵심 정보 분석',
description: '홈페이지, 네이버 지도, 블로그 등의 URL을 입력하세요',
},
{
id: 2,
title: '홍보 콘텐츠 자동 제작',
description: '분석 정보를 바탕으로 제작합니다',
},
{
id: 3,
title: '멀티채널 자동 배포',
description: '다운로드 또는 SNS 즉시 업로드',
},
];
return (
<div className="w-full h-full flex flex-col bg-[#121a1d] text-white overflow-hidden">
<Header />
<div className="content-safe-area pt-20 md:pt-24">
<div className="mb-6 md:mb-10 text-center">
<div className="inline-block mb-2 text-[#a682ff]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l2.4 7.2L22 12l-7.6 2.4L12 22l-2.4-7.2L2 12l7.6-2.4z" />
</svg>
</div>
<h2 className="text-xl sm:text-2xl md:text-3xl lg:text-4xl font-bold mb-2">
CASTAD .
</h2>
<p className="text-gray-400 text-xs sm:text-sm md:text-base">
, ,
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4 md:gap-6 w-full max-w-5xl mb-8 overflow-y-auto md:overflow-visible pr-1">
{features.map((feature) => (
<div
key={feature.id}
className="relative bg-[#1c2a2e] rounded-2xl md:rounded-3xl p-4 sm:p-6 md:p-8 flex flex-row md:flex-col items-center md:items-center text-left md:text-center transition-all border border-transparent"
>
<div className="w-6 h-6 sm:w-8 sm:h-8 shrink-0 rounded-full border border-gray-600 flex items-center justify-center mr-4 md:mr-0 md:mb-6 text-[10px] sm:text-xs font-bold text-gray-400">
{feature.id}
</div>
<div className="flex flex-col md:items-center">
<h3 className="text-sm sm:text-base md:text-lg font-bold mb-1 md:mb-4">{feature.title}</h3>
<p className="text-gray-400 text-[10px] sm:text-xs md:text-sm leading-tight md:leading-relaxed">
{feature.description}
</p>
</div>
</div>
))}
</div>
<div className="flex gap-4">
<button
onClick={onStartClick}
className="py-3 px-8 rounded-full border border-gray-700 hover:bg-white/5 transition-all text-white font-bold text-xs sm:text-sm mb-4"
>
</button>
<button
onClick={onNext}
className="py-3 px-10 md:px-14 rounded-full bg-[#a682ff] hover:bg-[#9570f0] transition-all transform active:scale-95 text-white font-bold text-xs sm:text-sm shadow-lg shadow-[#a682ff33] mb-4"
>
</button>
</div>
</div>
</div>
);
};
export default WelcomeSection;

View File

@ -0,0 +1,42 @@
import React from 'react';
interface LoginSectionProps {
onBack: () => void;
onLogin: () => void;
}
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
return (
<div className="w-full h-[100dvh] bg-[#121a1d] flex flex-col items-center justify-center text-white px-4 sm:px-6 py-4 relative overflow-hidden">
{/* Back Button */}
<button
onClick={onBack}
className="absolute top-3 sm:top-4 left-3 sm:left-4 flex items-center gap-1.5 py-1 px-3 sm:py-1.5 sm:px-4 rounded-full border border-gray-700 hover:bg-gray-800 transition-colors text-[9px] sm:text-[10px] md:text-xs text-gray-300"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<div className="flex flex-col items-center text-center max-w-2xl">
<h1 className="font-serif-logo italic text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold mb-2 sm:mb-3 tracking-tight leading-none text-white">
CASTAD
</h1>
<p className="text-[9px] sm:text-[10px] md:text-xs lg:text-sm font-light mb-6 sm:mb-8 md:mb-10 opacity-80">
Marketing Automation for Location Based Business
</p>
<button
onClick={onLogin}
className="w-full max-w-[200px] sm:max-w-[240px] py-2.5 sm:py-3 px-4 rounded-full bg-[#fae100] hover:bg-[#ebd500] transition-all transform active:scale-95 text-[#121a1d] font-bold text-[10px] sm:text-xs shadow-xl shadow-black/20"
>
</button>
</div>
</div>
);
};
export default LoginSection;

96
src/types/api.ts Normal file
View File

@ -0,0 +1,96 @@
export interface CrawlingResponse {
image_list: string[];
image_count: number;
processed_info: {
customer_name: string;
region: string;
detail_region_info: string;
};
marketing_analysis: {
report: string;
tags: string[];
facilities: string[];
};
}
// URL 이미지 (서버에서 가져온 이미지)
export interface UrlImage {
type: 'url';
url: string;
}
// 업로드된 파일 이미지
export interface FileImage {
type: 'file';
file: File;
preview: string; // createObjectURL로 생성된 미리보기 URL
}
export type ImageItem = UrlImage | FileImage;
// 가사 생성 요청
export interface LyricGenerateRequest {
customer_name: string;
detail_region_info: string;
language: string;
region: string;
}
// 가사 생성 응답
export interface LyricGenerateResponse {
success: boolean;
lyric: string;
language: string;
prompt_used: string;
error_message: string | null;
}
// 노래 생성 요청
export interface SongGenerateRequest {
genre: string;
language: string;
lyrics: string;
}
// 노래 생성 응답
export interface SongGenerateResponse {
success: boolean;
task_id: string;
message: string;
error_message: string | null;
}
// 노래 상태 확인 응답
export interface SongStatusResponse {
success: boolean;
status: string;
message?: string;
clips?: Array<{
id: string;
audio_url: string;
stream_audio_url: string;
image_url: string;
title: string;
status: string | null;
duration: number;
}>;
}
// 노래 다운로드 응답
export interface SongDownloadResponse {
success: boolean;
message: string;
file_path: string;
file_url: string;
error_message: string | null;
}
// 언어 매핑
export const LANGUAGE_MAP: Record<string, string> = {
'한국어': 'Korean',
'English': 'English',
'中文': 'Chinese',
'日本語': 'Japanese',
'ไทย': 'Thai',
'Tiếng Việt': 'Vietnamese',
};

142
src/utils/api.ts Normal file
View File

@ -0,0 +1,142 @@
import {
CrawlingResponse,
LyricGenerateRequest,
LyricGenerateResponse,
SongGenerateRequest,
SongGenerateResponse,
SongStatusResponse,
SongDownloadResponse,
} from '../types/api';
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
export async function crawlUrl(url: string): Promise<CrawlingResponse> {
const response = await fetch(`${API_URL}/crawling`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 가사 생성 API
export async function generateLyric(request: LyricGenerateRequest): Promise<LyricGenerateResponse> {
const response = await fetch(`${API_URL}/lyric/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 노래 생성 API
export async function generateSong(request: SongGenerateRequest): Promise<SongGenerateResponse> {
const response = await fetch(`${API_URL}/song/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 노래 상태 확인 API
export async function getSongStatus(taskId: string): Promise<SongStatusResponse> {
const response = await fetch(`${API_URL}/song/status/${taskId}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 노래 다운로드 API
export async function downloadSong(taskId: string): Promise<SongDownloadResponse> {
const response = await fetch(`${API_URL}/song/download/${taskId}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 노래 생성 완료까지 폴링 (2분 타임아웃)
const POLL_TIMEOUT = 2 * 60 * 1000; // 2분
const POLL_INTERVAL = 5000; // 5초
export async function waitForSongComplete(
taskId: string,
onStatusChange?: (status: string) => void
): Promise<SongDownloadResponse> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const pollInterval = setInterval(async () => {
try {
// 2분 타임아웃 체크
if (Date.now() - startTime > POLL_TIMEOUT) {
clearInterval(pollInterval);
reject(new Error('TIMEOUT'));
return;
}
const statusResponse = await getSongStatus(taskId);
onStatusChange?.(statusResponse.status);
// status가 "SUCCESS" (대문자)인 경우 완료
if (statusResponse.status === 'SUCCESS' && statusResponse.success) {
clearInterval(pollInterval);
// clips에서 첫 번째 오디오 URL 가져오기
if (statusResponse.clips && statusResponse.clips.length > 0) {
const clip = statusResponse.clips[0];
resolve({
success: true,
message: statusResponse.message || '노래 생성이 완료되었습니다.',
file_path: '',
file_url: clip.audio_url || clip.stream_audio_url,
error_message: null,
});
} else {
// clips가 없으면 download API 호출
const downloadResponse = await downloadSong(taskId);
resolve(downloadResponse);
}
} else if (statusResponse.status === 'FAILED' || statusResponse.status === 'failed') {
clearInterval(pollInterval);
reject(new Error('Song generation failed'));
}
// PENDING, PROCESSING 등은 계속 폴링
} catch (error) {
clearInterval(pollInterval);
reject(error);
}
}, POLL_INTERVAL);
});
}

29
tsconfig.json Executable file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./src/*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

20
vite.config.ts Executable file
View File

@ -0,0 +1,20 @@
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig({
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
}
}
});