castad front 작업
parent
aa39cde4c6
commit
a42562279e
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(mv:*)",
|
||||||
|
"Bash(rmdir:*)",
|
||||||
|
"Bash(rm:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Local server
|
||||||
|
VITE_PORT=3000
|
||||||
|
VITE_HOST=localhost
|
||||||
|
|
||||||
|
# API server
|
||||||
|
VITE_API_URL=http://40.82.133.44
|
||||||
|
|
@ -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?
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue