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