css 정리 .

main
hbyang 2025-12-24 13:09:05 +09:00
parent 97577b6ba3
commit ecbd81d091
16 changed files with 3778 additions and 485 deletions

3319
index.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import HeroSection from './pages/Landing/HeroSection'; import HeroSection from './pages/Landing/HeroSection';
import WelcomeSection from './pages/Landing/WelcomeSection'; import WelcomeSection from './pages/Landing/WelcomeSection';
import DisplaySection from './pages/Landing/DisplaySection'; import DisplaySection from './pages/Landing/DisplaySection';
@ -12,13 +12,30 @@ import { CrawlingResponse } from './types/api';
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow'; type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
const VIEW_MODE_KEY = 'castad_view_mode';
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
const App: React.FC = () => { const App: React.FC = () => {
const containerRef = useRef<HTMLElement>(null); const containerRef = useRef<HTMLElement>(null);
const [viewMode, setViewMode] = useState<ViewMode>('landing');
// localStorage에서 저장된 상태 복원
const savedViewMode = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null;
const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY);
const [viewMode, setViewMode] = useState<ViewMode>(
savedViewMode === 'generation_flow' ? 'generation_flow' : 'landing'
);
const [initialTab, setInitialTab] = useState('새 프로젝트 만들기'); const [initialTab, setInitialTab] = useState('새 프로젝트 만들기');
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(null); const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(
savedAnalysisData ? JSON.parse(savedAnalysisData) : null
);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// viewMode 변경 시 localStorage에 저장
useEffect(() => {
localStorage.setItem(VIEW_MODE_KEY, viewMode);
}, [viewMode]);
const scrollToSection = (index: number) => { const scrollToSection = (index: number) => {
if (containerRef.current) { if (containerRef.current) {
const h = containerRef.current.clientHeight; const h = containerRef.current.clientHeight;
@ -38,6 +55,7 @@ const App: React.FC = () => {
try { try {
const data = await crawlUrl(url); const data = await crawlUrl(url);
setAnalysisData(data); setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
setViewMode('analysis'); setViewMode('analysis');
} catch (err) { } catch (err) {
console.error('Crawling failed:', err); console.error('Crawling failed:', err);
@ -56,6 +74,11 @@ const App: React.FC = () => {
}; };
const handleGoBack = () => { const handleGoBack = () => {
// localStorage 정리
localStorage.removeItem(VIEW_MODE_KEY);
localStorage.removeItem(ANALYSIS_DATA_KEY);
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
setViewMode('landing'); setViewMode('landing');
}; };

View File

@ -3,15 +3,12 @@ import React from 'react';
const Header: React.FC = () => { const Header: React.FC = () => {
return ( 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"> <header className="landing-header">
<div className="font-serif-logo italic text-xl sm:text-2xl font-bold tracking-tight text-white"> <div className="header-logo">CASTAD</div>
CASTAD <div className="header-avatar">
</div>
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full overflow-hidden border border-gray-700 shadow-sm">
<img <img
src="https://picsum.photos/seed/user/100/100" src="https://picsum.photos/seed/user/100/100"
alt="User Profile" alt="User Profile"
className="w-full h-full object-cover"
/> />
</div> </div>
</header> </header>

View File

@ -10,20 +10,16 @@ interface SidebarItemProps {
} }
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, onClick }) => { 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 ( return (
<div <div
onClick={onClick} onClick={onClick}
className={`${baseClasses} ${isActive ? activeClasses : inactiveClasses} ${isCollapsed ? 'justify-center w-10 h-10 sm:w-12 sm:h-12 mx-auto px-0' : ''}`} className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''}`}
title={isCollapsed ? label : ""} title={isCollapsed ? label : ""}
> >
<div className={`shrink-0 flex items-center justify-center transition-colors ${isActive ? 'text-[#121a1d]' : ''}`}> <div className="sidebar-item-icon">
{icon} {icon}
</div> </div>
{!isCollapsed && <span className="text-xs sm:text-sm font-bold whitespace-nowrap">{label}</span>} {!isCollapsed && <span className="sidebar-item-label">{label}</span>}
</div> </div>
); );
}; };
@ -31,13 +27,13 @@ const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isColl
interface SidebarProps { interface SidebarProps {
activeItem: string; activeItem: string;
onNavigate: (id: string) => void; onNavigate: (id: string) => void;
onHome?: () => void;
} }
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate }) => { const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome }) => {
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false); const [isMobileOpen, setIsMobileOpen] = useState(false);
// 모바일에서는 기본적으로 사이드바 숨기기
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
@ -50,7 +46,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate }) => {
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
// 네비게이션 시 모바일에서 사이드바 닫기
const handleNavigate = (id: string) => { const handleNavigate = (id: string) => {
onNavigate(id); onNavigate(id);
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
@ -59,43 +54,43 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate }) => {
}; };
const menuItems = [ const menuItems = [
{ 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"><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"><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"><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"><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="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"><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> }, { 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 ( return (
<> <>
{/* 모바일 햄버거 버튼 */} {/* Mobile Menu Button */}
<button <button
onClick={() => setIsMobileOpen(true)} 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" className="mobile-menu-btn"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="22" height="22" 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"/> <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> </svg>
</button> </button>
{/* 모바일 오버레이 */} {/* Mobile Overlay */}
{isMobileOpen && ( {isMobileOpen && (
<div <div
className="fixed inset-0 bg-black/50 z-40 md:hidden" className="mobile-overlay"
onClick={() => setIsMobileOpen(false)} onClick={() => setIsMobileOpen(false)}
/> />
)} )}
{/* 사이드바 */} {/* Sidebar */}
<div className={` <div className={`sidebar ${isCollapsed ? 'collapsed' : 'expanded'} ${isMobileOpen ? 'mobile-open' : 'mobile-closed'}`}>
fixed md:relative h-screen flex flex-col bg-[#121a1d] border-r border-white/5 transition-all duration-300 z-50 <div className={`sidebar-header ${isCollapsed ? 'collapsed' : ''}`}>
${isCollapsed ? 'w-16 sm:w-20' : 'w-56 sm:w-64'} {!isCollapsed && (
${isMobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} <span onClick={onHome} className="sidebar-logo">
`}> CASTAD
<div className={`p-4 sm:p-6 flex items-center justify-between ${isCollapsed ? 'flex-col gap-4' : ''}`}> </span>
{!isCollapsed && <span className="font-serif-logo italic text-xl sm:text-2xl font-bold tracking-tight text-white">CASTAD</span>} )}
<button <button
onClick={() => { onClick={() => {
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
@ -104,15 +99,15 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate }) => {
setIsCollapsed(!isCollapsed); setIsCollapsed(!isCollapsed);
} }
}} }}
className="p-1 text-gray-400 hover:text-white" className="p-1.5 text-gray-400 hover:text-white"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="22" height="22" 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"/> <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> </svg>
</button> </button>
</div> </div>
<div className="flex-1 px-3 sm:px-4 mt-2 sm:mt-4 overflow-y-auto no-scrollbar"> <div className="sidebar-menu no-scrollbar">
{menuItems.map(item => ( {menuItems.map(item => (
<SidebarItem <SidebarItem
key={item.id} key={item.id}
@ -125,40 +120,40 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate }) => {
))} ))}
</div> </div>
<div className="p-3 sm:p-4 mt-auto space-y-3 sm:space-y-4"> <div className="sidebar-footer">
{!isCollapsed && ( {!isCollapsed && (
<div className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl p-3 sm:p-4 border border-white/5"> <div className="credit-card">
<div className="flex justify-between items-center mb-2"> <div className="credit-header">
<span className="text-gray-400 text-[9px] sm:text-[10px] font-medium">Credit</span> <span className="credit-label">Credit</span>
<span className="text-white text-[9px] sm:text-[10px] font-bold">850 / 1000</span> <span className="credit-value">850 / 1000</span>
</div> </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="credit-bar">
<div className="h-full bg-[#a6ffea]" style={{ width: '85%' }}></div> <div className="credit-bar-fill" style={{ width: '85%' }}></div>
</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 className="credit-upgrade-btn">
</button> </button>
</div> </div>
)} )}
<div className={`flex items-center gap-2 sm:gap-3 px-2 ${isCollapsed ? 'flex-col' : ''}`}> <div className={`profile-section ${isCollapsed ? 'collapsed' : ''}`}>
<img <img
src="https://picsum.photos/seed/user/100/100" src="https://picsum.photos/seed/user/100/100"
alt="Profile" alt="Profile"
className="w-8 h-8 sm:w-10 sm:h-10 rounded-full border border-gray-700" className="profile-avatar"
/> />
{!isCollapsed && ( {!isCollapsed && (
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-white text-[10px] sm:text-xs font-bold truncate">username1234</p> <p className="profile-name">username1234</p>
</div> </div>
)} )}
</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' : ''}`}> <button className={`logout-btn ${isCollapsed ? 'collapsed' : ''}`}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="20" height="20" 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"/> <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> </svg>
{!isCollapsed && <span className="text-[10px] sm:text-xs font-bold"></span>} {!isCollapsed && <span className="logout-btn-label"></span>}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import React from 'react'; import React, { useState } from 'react';
import { CrawlingResponse } from '../../types/api'; import { CrawlingResponse } from '../../types/api';
interface AnalysisResultSectionProps { interface AnalysisResultSectionProps {
@ -8,119 +8,153 @@ interface AnalysisResultSectionProps {
data: CrawlingResponse; data: CrawlingResponse;
} }
// 마크다운 report를 섹션별로 파싱
const parseReport = (report: string) => {
const sections: { title: string; content: string }[] = [];
const lines = report.split('\n');
let currentTitle = '';
let currentContent: string[] = [];
lines.forEach((line) => {
if (line.startsWith('## ')) {
if (currentTitle || currentContent.length > 0) {
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
}
currentTitle = line.replace('## ', '').trim();
currentContent = [];
} else if (!line.startsWith('# ')) {
currentContent.push(line);
}
});
if (currentTitle || currentContent.length > 0) {
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
}
return sections.filter(s => s.title && s.content && !s.title.includes('JSON'));
};
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => { const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
const { processed_info, marketing_analysis, image_list } = data; const { processed_info, marketing_analysis, image_list } = data;
const tags = marketing_analysis.tags || []; const tags = marketing_analysis.tags || [];
const facilities = marketing_analysis.facilities || []; const facilities = marketing_analysis.facilities || [];
const [showFullReport, setShowFullReport] = useState(false);
const reportSections = parseReport(marketing_analysis.report);
return ( 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="analysis-container">
{/* 뒤로가기 버튼 */} {/* Back Button */}
<div className="w-full flex justify-start mb-2 sm:mb-3 shrink-0"> <div className="back-button-container" style={{ marginLeft: 0 }}>
<button <button onClick={onBack} className="btn-back">
onClick={onBack} <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
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" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
</button> </button>
</div> </div>
{/* 헤더 - 높이가 작을 때 숨김 */} {/* Header */}
<div className="hidden sm:flex flex-col items-center text-center mb-2 md:mb-4 shrink-0"> <div className="analysis-header">
<div className="text-[#a682ff] mb-1 animate-bounce"> <div className="analysis-icon">
<svg className="w-4 h-4 md:w-5 md:h-5" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-8 h-8" 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" /> <path d="M12 2l2.4 7.2L22 12l-7.6 2.4L12 22l-2.4-7.2L2 12l7.6-2.4z" />
</svg> </svg>
</div> </div>
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5"> </h1> <h1 className="page-title"> </h1>
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light max-w-lg px-4"> <p className="page-subtitle">
, . , .
</p> </p>
</div> </div>
{/* 메인 콘텐츠 그리드 */} {/* Main Content Grid */}
<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="analysis-grid">
{/* 브랜드 정체성 */} {/* Brand Identity */}
<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"> <div className="brand-identity-card">
<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> <div className="brand-header">
<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> <span className="section-title mb-0"> </span>
<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> <span className="text-gray-500 text-sm">AI </span>
</div>
{/* 이미지 미리보기 */} <h2 className="brand-name">{processed_info.customer_name}</h2>
<p className="brand-location">{processed_info.region} · {processed_info.detail_region_info}</p>
{/* Marketing Analysis Summary */}
<div className="flex-1">
<div className="flex-between mb-3">
<button
onClick={() => setShowFullReport(!showFullReport)}
className="report-toggle"
>
{showFullReport ? '간략히 보기' : '자세히 보기'}
</button>
</div>
<div className="report-content custom-scrollbar">
{showFullReport ? (
<div className="space-y-4">
{reportSections.map((section, idx) => (
<div key={idx}>
<h4 className="report-section-title">{section.title}</h4>
<p className="text-gray-300" style={{ lineHeight: 1.625 }}>{section.content}</p>
</div>
))}
</div>
) : (
<p className="text-gray-300">
{reportSections[0]?.content.slice(0, 150)}...
</p>
)}
</div>
</div>
{/* Image Preview */}
{image_list.length > 0 && ( {image_list.length > 0 && (
<div className="mt-auto min-h-0 overflow-hidden"> <div className="image-preview-section">
<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> <span className="image-preview-title"> ({image_list.length})</span>
<div className="grid grid-cols-4 gap-1 sm:gap-1.5"> <div className="image-preview-grid">
{image_list.slice(0, 8).map((img, idx) => ( {image_list.slice(0, 8).map((img, idx) => (
<div key={idx} className="aspect-square rounded sm:rounded-md overflow-hidden bg-[#121a1d]"> <div key={idx} className="image-preview-item">
<img src={img} alt={`이미지 ${idx + 1}`} className="w-full h-full object-cover" /> <img src={img} alt={`이미지 ${idx + 1}`} />
</div> </div>
))} ))}
</div> </div>
{image_list.length > 8 && ( {image_list.length > 8 && (
<p className="text-gray-500 text-[9px] sm:text-[10px] mt-1 text-center">+{image_list.length - 8} </p> <p className="image-preview-more">+{image_list.length - 8} </p>
)} )}
</div> </div>
)} )}
</div> </div>
{/* 오른쪽 카드들 */} {/* Right Cards */}
<div className="flex flex-col gap-2 sm:gap-3 overflow-hidden"> <div className="analysis-cards-column">
{/* 시설 정보 */} {/* Main Selling Points (Facilities) */}
<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"> <div className="feature-card">
<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> <span className="section-title-purple"> </span>
<div className="flex flex-wrap gap-1 sm:gap-1.5"> <div className="tags-wrapper">
{facilities.slice(0, 6).map((facility, idx) => ( {facilities.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"> <span key={idx} className="feature-tag">
{facility} {facility}
</span> </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> </div>
{/* 추천 태그 */} {/* Recommended Target Keywords (Tags) */}
<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"> <div className="feature-card">
<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> <span className="section-title"> </span>
<div className="flex flex-wrap gap-1 sm:gap-1.5"> <div className="tags-wrapper">
{tags.slice(0, 6).map((tag, idx) => ( {tags.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"> <span key={idx} className="feature-tag">
{tag} {tag}
</span> </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> </div>
</div> </div>
{/* 하단 버튼 */} {/* Bottom Button */}
<div className="mt-2 sm:mt-3 md:mt-4 flex justify-center shrink-0 py-2 sm:py-3 md:py-4"> <div className="analysis-bottom">
<button <button onClick={onGenerate} className="btn-primary">
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> </button>
</div> </div>

View File

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

View File

@ -20,7 +20,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const imageListRef = useRef<HTMLDivElement>(null); const imageListRef = useRef<HTMLDivElement>(null);
// 이미지 아이템의 표시용 URL 가져오기
const getImageSrc = (item: ImageItem): string => { const getImageSrc = (item: ImageItem): string => {
return item.type === 'url' ? item.url : item.preview; return item.type === 'url' ? item.url : item.preview;
}; };
@ -37,7 +36,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const atTop = scrollTop <= 0; const atTop = scrollTop <= 0;
const atBottom = scrollTop + clientHeight >= scrollHeight - 1; const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
// 스크롤 가능한 영역 내에서 스크롤 중이면 이벤트 전파 중지
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) { if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
e.stopPropagation(); e.stopPropagation();
} }
@ -69,38 +67,37 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const files = e.target.files; const files = e.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
onAddImages(Array.from(files)); onAddImages(Array.from(files));
// input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = ''; e.target.value = '';
} }
}; };
return ( return (
<main className="flex flex-col h-full p-3 sm:p-4 md:p-6 overflow-hidden"> <main className="page-container">
<div className="flex justify-start mb-2 sm:mb-3 ml-10 md:ml-0 shrink-0"> {/* Back Button */}
<button <div className="back-button-container">
onClick={onBack} <button onClick={onBack} className="btn-back">
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="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
</button> </button>
</div> </div>
<div className="hidden sm:block text-center mb-2 md:mb-4 shrink-0"> {/* Header */}
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5 tracking-tight"> </h1> <div className="page-header">
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light"> <h1 className="page-title"> </h1>
<p className="page-subtitle">
, . , .
</p> </p>
</div> </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"> {/* Main Content */}
{/* 선택된 이미지 */} <div className="main-content">
<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"> {/* Selected Images Card */}
<div className="flex justify-between items-center mb-2 sm:mb-3 shrink-0"> <div className="card card-flex card-flex-2 card-overflow-hidden">
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold tracking-wide"> </h3> <div className="flex-between mb-4 shrink-0">
<span className="text-gray-500 text-[8px] sm:text-[9px] md:text-[10px]">{imageList.length}</span> <h3 className="section-title mb-0"> </h3>
<span className="text-gray-400 text-sm">{imageList.length}</span>
</div> </div>
<div <div
ref={imageListRef} ref={imageListRef}
@ -109,28 +106,21 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
style={{ overscrollBehavior: 'contain' }} style={{ overscrollBehavior: 'contain' }}
> >
{imageList.length > 0 ? ( {imageList.length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-1.5 sm:gap-2"> <div className="image-grid">
{imageList.map((item, i) => ( {imageList.map((item, i) => (
<div <div key={i} className="image-item">
key={i}
className="aspect-square bg-[#121a1d]/50 rounded-lg border border-white/5 relative overflow-hidden group"
>
<img <img
src={getImageSrc(item)} src={getImageSrc(item)}
alt={`이미지 ${i + 1}`} alt={`이미지 ${i + 1}`}
className="w-full h-full object-cover"
/> />
{/* 업로드된 파일 표시 배지 */}
{item.type === 'file' && ( {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"> <div className="image-item-badge">NEW</div>
NEW
</div>
)} )}
<button <button
onClick={() => onRemoveImage(i)} 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" className="image-item-remove"
> >
<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"> <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/> <line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/> <line x1="6" y1="6" x2="18" y2="18"/>
</svg> </svg>
@ -139,33 +129,33 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
))} ))}
</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 className="flex-center text-gray-500 text-base h-full">
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* 이미지 업로드 */} {/* Upload Card */}
<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]"> <div className="card card-flex card-flex-1" style={{ minHeight: '200px' }}>
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0"> </h3> <h3 className="section-title"> </h3>
<div <div
onClick={handleFileSelect} onClick={handleFileSelect}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} 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" className="upload-zone"
> >
<div className="mb-2 text-gray-500 group-hover:text-[#a6ffea]"> <div className="upload-zone-icon">
<svg className="w-6 h-6 sm:w-8 sm:h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <svg className="w-12 h-12" 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"/> <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"/> <polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/> <line x1="12" y1="3" x2="12" y2="15"/>
</svg> </svg>
</div> </div>
<p className="text-gray-400 text-[8px] sm:text-[9px] md:text-[10px] font-medium leading-relaxed"> <p className="upload-zone-title">
<br/> <br/>
</p> </p>
<p className="text-gray-600 text-[7px] sm:text-[8px] mt-1"> <p className="upload-zone-subtitle">
</p> </p>
</div> </div>
@ -180,15 +170,12 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
</div> </div>
</div> </div>
<div className="flex justify-center py-2 sm:py-3 md:py-4 shrink-0"> {/* Bottom Button */}
<div className="bottom-button-container">
<button <button
onClick={onNext} onClick={onNext}
disabled={imageList.length === 0} 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 ${ className={`btn-primary ${imageList.length === 0 ? 'disabled' : ''}`}
imageList.length > 0
? 'bg-[#a682ff] hover:bg-[#9570f0] text-white shadow-[#a682ff44]'
: 'bg-gray-600 text-gray-400 cursor-not-allowed shadow-none'
}`}
> >
</button> </button>

View File

@ -2,32 +2,32 @@
import React from 'react'; import React from 'react';
const SocialItem: React.FC<{ platform: string; icon: React.ReactNode; color: string }> = ({ platform, icon, color }) => ( 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="social-item">
<div className="flex items-center gap-2 sm:gap-3"> <div className="social-item-left">
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: color }}> <div className="social-item-icon" style={{ backgroundColor: color }}>
<div className="w-3 h-3 sm:w-4 sm:h-4">{icon}</div> <div className="social-item-icon-inner">{icon}</div>
</div> </div>
<span className="text-[10px] sm:text-xs font-bold text-gray-300">{platform}</span> <span className="social-item-name">{platform}</span>
</div> </div>
<button className="text-[#a6ffea] text-[9px] sm:text-[10px] font-bold hover:underline"></button> <button className="social-item-connect"></button>
</div> </div>
); );
const BusinessSettingsContent: React.FC = () => { const BusinessSettingsContent: React.FC = () => {
return ( 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="settings-container">
<div className="text-center mb-4 sm:mb-6 md:mb-8 max-w-2xl ml-10 md:ml-0"> <div className="settings-header">
<h1 className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold mb-1 sm:mb-2"> </h1> <h1 className="settings-title"> </h1>
<p className="text-gray-400 text-[9px] sm:text-[10px] md:text-xs font-light leading-relaxed opacity-80"> <p className="settings-description">
YouTube YouTube
</p> </p>
</div> </div>
<div className="w-full max-w-xl"> <div className="settings-card">
<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"> <div className="settings-card-inner">
<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> <h2 className="settings-card-title"></h2>
<div className="space-y-2 sm:space-y-3"> <div className="social-items">
<SocialItem <SocialItem
platform="Youtube" platform="Youtube"
color="#ff0000" color="#ff0000"

View File

@ -17,56 +17,56 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
}; };
const socials = [ 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: 'Youtube', email: 'o2ocorp@o2o.kr', color: '#ff0000', icon: <svg width="24" height="24" 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: 'Instagram', email: 'o2ocorp@o2o.kr', color: '#e4405f', icon: <svg width="24" height="24" 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 ( return (
<main className="flex flex-col h-full p-3 sm:p-4 md:p-6 overflow-hidden"> <main className="completion-container">
<div className="flex justify-start mb-2 sm:mb-3 ml-10 md:ml-0 shrink-0"> {/* Back Button */}
<button <div className="back-button-container">
onClick={onBack} <button onClick={onBack} className="btn-back">
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="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
</button> </button>
</div> </div>
<div className="hidden sm:block text-center mb-2 md:mb-4 shrink-0"> {/* Header */}
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5 tracking-tight"> </h1> <div className="page-header">
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light"> <h1 className="page-title"> </h1>
<p className="page-subtitle">
. .
</p> </p>
</div> </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"> {/* Main Content */}
<div className="completion-content">
{/* Left: Video Preview */} {/* 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"> <div className="video-preview-card">
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0"> </h3> <h3 className="section-title mb-4 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="video-container">
<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="video-pattern"></div>
<div className="absolute bottom-2 sm:bottom-3 left-2 sm:left-3 right-2 sm:right-3 z-10"> {/* Video Player Controls */}
<div className="w-full h-1 bg-white/20 rounded-full overflow-hidden"> <div className="video-controls">
<div className="w-[40%] h-full bg-[#a6ffea]"></div> <div className="video-controls-inner">
</div> <button className="video-play-btn">
<div className="flex items-center gap-2 sm:gap-3 mt-2"> <svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<button className="text-white hover:text-[#a6ffea]"> </button>
<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> <div className="video-progress">
</button> <div className="video-progress-fill" style={{ width: '40%' }}></div>
</div> </div>
</div> </div>
</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"> {/* Tags */}
{['AI 최적화', '색상 보정', '자막', '비트 싱크', 'SEO'].map(tag => ( <div className="tags-container">
<span key={tag} className="flex items-center gap-1 text-[8px] sm:text-[9px] text-gray-400"> {['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => (
<span className="w-1 h-1 rounded-full bg-[#a6ffea]"></span> <span key={tag} className="tag-dot">
{tag} {tag}
</span> </span>
))} ))}
@ -74,33 +74,33 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
</div> </div>
{/* Right: Sharing */} {/* 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"> <div className="sharing-card">
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0"> </h3> <h3 className="section-title mb-6 shrink-0"></h3>
<div className="space-y-2 sm:space-y-3 flex-1 min-h-0 overflow-y-auto custom-scrollbar"> <div className="social-list custom-scrollbar">
{socials.map(social => { {socials.map(social => {
const isSelected = selectedSocials.includes(social.id); const isSelected = selectedSocials.includes(social.id);
return ( return (
<div <div
key={social.id} key={social.id}
onClick={() => toggleSocial(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 ${ className={`social-card ${isSelected ? 'selected' : ''}`}
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
<div className="w-3 h-3 sm:w-4 sm:h-4">{social.icon}</div> className="social-icon"
style={{ backgroundColor: social.color }}
>
<div className="social-icon-inner">{social.icon}</div>
{isSelected && ( {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]"> <div className="social-check">
<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> <svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4"><polyline points="20 6 9 17 4 12"/></svg>
</div> </div>
)} )}
</div> </div>
<span className={`text-[10px] sm:text-xs font-bold tracking-tight transition-colors ${isSelected ? 'text-[#a6ffea]' : 'text-gray-500'}`}> <span className="social-name">
{social.id} {social.id}
</span> </span>
<span className="social-email">{social.email}</span>
</div> </div>
); );
})} })}
@ -108,22 +108,16 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
<button <button
disabled={selectedSocials.length === 0} 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 ${ className="btn-deploy"
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>
<button className="btn-download">
MP4
</button> </button>
</div> </div>
</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> </main>
); );
}; };

View File

@ -2,40 +2,40 @@
import React from 'react'; import React from 'react';
const StatCard: React.FC<{ label: string; value: string; trend: string }> = ({ label, value, trend }) => ( 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]"> <div className="stat-card">
<span className="text-[8px] sm:text-[9px] md:text-[10px] font-bold text-gray-400 uppercase tracking-widest">{label}</span> <span className="stat-label">{label}</span>
<h3 className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold text-white leading-tight">{value}</h3> <h3 className="stat-value">{value}</h3>
<span className="text-[8px] sm:text-[9px] text-[#a6ffea] font-medium">{trend}</span> <span className="stat-trend">{trend}</span>
</div> </div>
); );
const DashboardContent: React.FC = () => { const DashboardContent: React.FC = () => {
return ( 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="dashboard-container">
<div className="shrink-0 mb-2 sm:mb-3 md:mb-4 ml-10 md:ml-0"> <div className="dashboard-header">
<h1 className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight"></h1> <h1 className="dashboard-title"></h1>
<p className="text-[9px] sm:text-[10px] text-gray-500 mt-0.5"> .</p> <p className="dashboard-description"> .</p>
</div> </div>
{/* Top Stats Grid - Fixed height based on content */} {/* Top Stats Grid */}
<div className="shrink-0 grid grid-cols-3 gap-2 sm:gap-3 mb-2 sm:mb-3 md:mb-4"> <div className="stats-grid">
<StatCard label="TOTAL REACH" value="124.5k" trend="지난 주 12%" /> <StatCard label="TOTAL REACH" value="124.5k" trend="지난 주 12%" />
<StatCard label="CONVERSIONS" value="3,892" trend="지난 주 8%" /> <StatCard label="CONVERSIONS" value="3,892" trend="지난 주 8%" />
<StatCard label="SEO SCORE" value="98/100" trend="지난 주 12%" /> <StatCard label="SEO SCORE" value="98/100" trend="지난 주 12%" />
</div> </div>
{/* Engagement Chart Section - Flex-1 to fill remaining space */} {/* Engagement Chart Section */}
<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="chart-card">
<div className="shrink-0 flex justify-between items-center mb-2 sm:mb-3 md:mb-4"> <div className="chart-header">
<h2 className="text-[8px] sm:text-[9px] md:text-[10px] font-bold text-gray-400 uppercase tracking-widest">Engagement Overview</h2> <h2 className="chart-title">Engagement Overview</h2>
<div className="flex gap-1.5 items-center"> <div className="chart-legend">
<span className="w-1.5 h-1.5 rounded-full bg-[#a6ffea]"></span> <span className="chart-legend-dot"></span>
<span className="text-[8px] sm:text-[9px] text-gray-400">Weekly Active</span> <span className="chart-legend-text">Weekly Active</span>
</div> </div>
</div> </div>
<div className="flex-1 relative min-h-0"> <div className="chart-container">
{/* Chart SVG - Responsive viewBox and preserveAspectRatio */} {/* Chart SVG */}
<svg <svg
className="w-full h-full overflow-visible" className="w-full h-full overflow-visible"
viewBox="0 0 1000 400" viewBox="0 0 1000 400"
@ -68,29 +68,25 @@ const DashboardContent: React.FC = () => {
fill="url(#chartGradient)" fill="url(#chartGradient)"
/> />
{/* Pulsing Dot at X=550, Y=180 */} {/* Pulsing Dot */}
<g transform="translate(550, 180)"> <g transform="translate(550, 180)">
<circle r="5" fill="#a6ffea"> <circle r="5" fill="#a6ffea">
<animate attributeName="r" from="5" to="15" dur="2s" repeatCount="indefinite" /> <animate attributeName="r" from="5" to="15" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" from="0.6" to="0" dur="2s" repeatCount="indefinite" /> <animate attributeName="opacity" from="0.6" to="0" dur="2s" repeatCount="indefinite" />
</circle> </circle>
<circle r="6" fill="#a6ffea" className="drop-shadow-[0_0_12px_rgba(166,255,234,0.8)]" /> <circle r="6" fill="#a6ffea" style={{ filter: 'drop-shadow(0 0 12px rgba(166,255,234,0.8))' }} />
</g> </g>
</svg> </svg>
{/* Floating Data Badge - Positioning relative to SVG percentage */} {/* Floating Data Badge */}
<div <div className="chart-badge">
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="chart-badge-value">1,234</div>
> <div className="chart-badge-line"></div>
<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>
</div> </div>
{/* X Axis Labels - Fixed bottom */} {/* X Axis Labels */}
<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"> <div className="chart-xaxis">
<span>Mon</span> <span>Mon</span>
<span>Tue</span> <span>Tue</span>
<span>Wed</span> <span>Wed</span>

View File

@ -8,6 +8,9 @@ import DashboardContent from './DashboardContent';
import BusinessSettingsContent from './BusinessSettingsContent'; import BusinessSettingsContent from './BusinessSettingsContent';
import { ImageItem } from '../../types/api'; import { ImageItem } from '../../types/api';
const WIZARD_STEP_KEY = 'castad_wizard_step';
const ACTIVE_ITEM_KEY = 'castad_active_item';
interface BusinessInfo { interface BusinessInfo {
customer_name: string; customer_name: string;
region: string; region: string;
@ -23,8 +26,13 @@ interface GenerationFlowProps {
const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveItem = '대시보드', initialImageList = [], businessInfo }) => { const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveItem = '대시보드', initialImageList = [], businessInfo }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const [activeItem, setActiveItem] = useState(initialActiveItem);
const [maxWizardIndex, setMaxWizardIndex] = useState(0); // localStorage에서 저장된 상태 복원
const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY);
const savedWizardStep = localStorage.getItem(WIZARD_STEP_KEY);
const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem);
const [maxWizardIndex, setMaxWizardIndex] = useState(savedWizardStep ? parseInt(savedWizardStep, 10) : 0);
// URL 이미지를 ImageItem 형태로 변환하여 초기화 // URL 이미지를 ImageItem 형태로 변환하여 초기화
const [imageList, setImageList] = useState<ImageItem[]>( const [imageList, setImageList] = useState<ImageItem[]>(
@ -56,11 +64,33 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
const sections = scrollContainerRef.current.querySelectorAll('.flow-section'); const sections = scrollContainerRef.current.querySelectorAll('.flow-section');
if (sections[index]) { if (sections[index]) {
setMaxWizardIndex(prev => Math.max(prev, index)); setMaxWizardIndex(prev => Math.max(prev, index));
localStorage.setItem(WIZARD_STEP_KEY, index.toString());
sections[index].scrollIntoView({ behavior: 'smooth', block: 'start' }); sections[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
} }
}; };
// activeItem 변경 시 localStorage에 저장
useEffect(() => {
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
}, [activeItem]);
// 새로고침 시 저장된 위저드 단계로 스크롤
useEffect(() => {
if (activeItem === '새 프로젝트 만들기' && savedWizardStep) {
const stepIndex = parseInt(savedWizardStep, 10);
// 약간의 딜레이 후 스크롤 (DOM이 준비될 때까지)
setTimeout(() => {
if (scrollContainerRef.current) {
const sections = scrollContainerRef.current.querySelectorAll('.flow-section');
if (sections[stepIndex]) {
sections[stepIndex].scrollIntoView({ behavior: 'auto', block: 'start' });
}
}
}, 100);
}
}, []);
useEffect(() => { useEffect(() => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
if (!container || activeItem !== '새 프로젝트 만들기') return; if (!container || activeItem !== '새 프로젝트 만들기') return;
@ -150,7 +180,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
return ( return (
<div className="flex w-full h-[100dvh] bg-[#0d1416] text-white overflow-hidden"> <div className="flex w-full h-[100dvh] bg-[#0d1416] text-white overflow-hidden">
<Sidebar activeItem={activeItem} onNavigate={setActiveItem} /> <Sidebar activeItem={activeItem} onNavigate={setActiveItem} onHome={onHome} />
<div className="flex-1 h-full relative overflow-hidden pl-0 md:pl-0"> <div className="flex-1 h-full relative overflow-hidden pl-0 md:pl-0">
{renderContent()} {renderContent()}
</div> </div>

View File

@ -17,7 +17,6 @@ interface SoundStudioContentProps {
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error'; type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
// localStorage에 저장할 데이터 구조
interface SavedGenerationState { interface SavedGenerationState {
taskId: string; taskId: string;
lyrics: string; lyrics: string;
@ -26,8 +25,8 @@ interface SavedGenerationState {
} }
const STORAGE_KEY = 'castad_song_generation'; const STORAGE_KEY = 'castad_song_generation';
const STORAGE_EXPIRY = 30 * 60 * 1000; // 30분 const STORAGE_EXPIRY = 30 * 60 * 1000;
const MAX_RETRY_COUNT = 3; // 최대 재시도 횟수 const MAX_RETRY_COUNT = 3;
const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext, businessInfo }) => { const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext, businessInfo }) => {
const [selectedType, setSelectedType] = useState('보컬'); const [selectedType, setSelectedType] = useState('보컬');
@ -49,7 +48,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
// localStorage에 상태 저장
const saveToStorage = (taskId: string, currentLyrics: string, currentStatus: GenerationStatus) => { const saveToStorage = (taskId: string, currentLyrics: string, currentStatus: GenerationStatus) => {
const data: SavedGenerationState = { const data: SavedGenerationState = {
taskId, taskId,
@ -60,12 +58,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}; };
// localStorage에서 상태 제거
const clearStorage = () => { const clearStorage = () => {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
}; };
// localStorage에서 상태 복구
const loadFromStorage = (): SavedGenerationState | null => { const loadFromStorage = (): SavedGenerationState | null => {
try { try {
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY);
@ -73,7 +69,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
const data: SavedGenerationState = JSON.parse(saved); const data: SavedGenerationState = JSON.parse(saved);
// 만료된 데이터인지 확인 (30분)
if (Date.now() - data.timestamp > STORAGE_EXPIRY) { if (Date.now() - data.timestamp > STORAGE_EXPIRY) {
clearStorage(); clearStorage();
return null; return null;
@ -86,26 +81,19 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
} }
}; };
// 컴포넌트 마운트 시 저장된 상태 복구
useEffect(() => { useEffect(() => {
const savedState = loadFromStorage(); const savedState = loadFromStorage();
if (savedState && (savedState.status === 'polling' || savedState.status === 'generating_song')) { if (savedState && (savedState.status === 'polling' || savedState.status === 'generating_song')) {
// 저장된 가사가 있으면 표시
if (savedState.lyrics) { if (savedState.lyrics) {
setLyrics(savedState.lyrics); setLyrics(savedState.lyrics);
setShowLyrics(true); setShowLyrics(true);
} }
// 폴링 상태 복구
setStatus('polling'); setStatus('polling');
setStatusMessage('음악을 처리하고 있습니다... (새로고침 후 복구됨)'); setStatusMessage('음악을 처리하고 있습니다... (새로고침 후 복구됨)');
// 폴링 재개 (저장된 가사와 함께)
resumePolling(savedState.taskId, savedState.lyrics, 0); resumePolling(savedState.taskId, savedState.lyrics, 0);
} }
}, []); }, []);
// 폴링 재개 함수 (타임아웃 시 재생성)
const resumePolling = async (taskId: string, currentLyrics: string, currentRetryCount: number = 0) => { const resumePolling = async (taskId: string, currentLyrics: string, currentRetryCount: number = 0) => {
try { try {
const downloadResponse = await waitForSongComplete( const downloadResponse = await waitForSongComplete(
@ -132,14 +120,11 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
} catch (error) { } catch (error) {
console.error('Polling failed:', error); console.error('Polling failed:', error);
// 타임아웃인 경우 재생성 시도
if (error instanceof Error && error.message === 'TIMEOUT') { if (error instanceof Error && error.message === 'TIMEOUT') {
if (currentRetryCount < MAX_RETRY_COUNT) { if (currentRetryCount < MAX_RETRY_COUNT) {
const newRetryCount = currentRetryCount + 1; const newRetryCount = currentRetryCount + 1;
setRetryCount(newRetryCount); setRetryCount(newRetryCount);
setStatusMessage(`시간 초과로 재생성 중... (${newRetryCount}/${MAX_RETRY_COUNT})`); setStatusMessage(`시간 초과로 재생성 중... (${newRetryCount}/${MAX_RETRY_COUNT})`);
// 새로운 노래 생성 요청
await regenerateSongOnly(currentLyrics, newRetryCount); await regenerateSongOnly(currentLyrics, newRetryCount);
} else { } else {
setStatus('error'); setStatus('error');
@ -156,7 +141,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
} }
}; };
// 노래만 재생성 (가사는 유지)
const regenerateSongOnly = async (currentLyrics: string, currentRetryCount: number) => { const regenerateSongOnly = async (currentLyrics: string, currentRetryCount: number) => {
if (!businessInfo) return; if (!businessInfo) return;
@ -179,10 +163,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.'); throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
} }
// 새 task_id로 저장
saveToStorage(songResponse.task_id, currentLyrics, 'polling'); saveToStorage(songResponse.task_id, currentLyrics, 'polling');
// 폴링 재개
await resumePolling(songResponse.task_id, currentLyrics, currentRetryCount); await resumePolling(songResponse.task_id, currentLyrics, currentRetryCount);
} catch (error) { } catch (error) {
@ -288,7 +269,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
setStatusMessage('가사를 생성하고 있습니다...'); setStatusMessage('가사를 생성하고 있습니다...');
try { try {
// Step 1: Generate lyrics
const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const language = LANGUAGE_MAP[selectedLang] || 'Korean';
let lyricResponse = await generateLyric({ let lyricResponse = await generateLyric({
customer_name: businessInfo.customer_name, customer_name: businessInfo.customer_name,
@ -297,7 +277,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
region: businessInfo.region, region: businessInfo.region,
}); });
// Retry if the response contains error message
let retryCount = 0; let retryCount = 0;
while (lyricResponse.lyric.includes("I'm sorry") && retryCount < 3) { while (lyricResponse.lyric.includes("I'm sorry") && retryCount < 3) {
retryCount++; retryCount++;
@ -314,7 +293,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
throw new Error(lyricResponse.error_message || '가사 생성에 실패했습니다.'); throw new Error(lyricResponse.error_message || '가사 생성에 실패했습니다.');
} }
// 3번 재시도 후에도 여전히 에러 메시지가 포함되어 있으면 가사 카드를 표시하지 않고 에러 처리
if (lyricResponse.lyric.includes("I'm sorry")) { if (lyricResponse.lyric.includes("I'm sorry")) {
throw new Error('가사 생성에 실패했습니다. 다시 시도해주세요.'); throw new Error('가사 생성에 실패했습니다. 다시 시도해주세요.');
} }
@ -322,7 +300,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
setLyrics(lyricResponse.lyric); setLyrics(lyricResponse.lyric);
setShowLyrics(true); setShowLyrics(true);
// Step 2: Generate song
setStatus('generating_song'); setStatus('generating_song');
setStatusMessage('음악을 생성하고 있습니다...'); setStatusMessage('음악을 생성하고 있습니다...');
@ -343,12 +320,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.'); throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
} }
// Step 3: Poll for completion - 상태 저장
setStatus('polling'); setStatus('polling');
setStatusMessage('음악을 처리하고 있습니다...'); setStatusMessage('음악을 처리하고 있습니다...');
saveToStorage(songResponse.task_id, lyricResponse.lyric, 'polling'); saveToStorage(songResponse.task_id, lyricResponse.lyric, 'polling');
// 폴링 시작 (타임아웃 시 자동 재생성)
await resumePolling(songResponse.task_id, lyricResponse.lyric, 0); await resumePolling(songResponse.task_id, lyricResponse.lyric, 0);
} catch (error) { } catch (error) {
@ -376,53 +351,50 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling'; const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
return ( return (
<div className="flex flex-col h-full overflow-hidden"> <div className="flex-col h-full overflow-hidden" style={{ display: 'flex' }}>
{/* Hidden audio element */}
{audioUrl && ( {audioUrl && (
<audio ref={audioRef} src={audioUrl} preload="metadata" /> <audio ref={audioRef} src={audioUrl} preload="metadata" />
)} )}
<div className="flex-shrink-0 p-3 sm:p-4 md:p-6 pb-0"> {/* Header Section */}
<div className="flex justify-start mb-2 sm:mb-3 ml-10 md:ml-0"> <div className="shrink-0 p-6 md:p-8 pb-0">
<button <div className="back-button-container">
onClick={onBack} <button onClick={onBack} className="btn-back">
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="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
</button> </button>
</div> </div>
<div className="hidden sm:block text-center mb-2 md:mb-4"> <div className="page-header">
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5 tracking-tight"> </h1> <h1 className="page-title"> </h1>
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light"> <p className="page-subtitle">
, . , .
</p> </p>
</div> </div>
</div> </div>
<div className="flex-1 px-3 sm:px-4 md:px-6 min-h-0 overflow-hidden"> <div className="flex-1 px-6 md:px-8 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"> <div className="main-content h-full justify-center">
{/* Left Card: Audio Style */} {/* Audio Style Card */}
<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-2xl flex flex-col transition-all duration-700 ease-out overflow-hidden ${ className={`card card-flex card-overflow-hidden transition-all duration-700 ease-out ${
showLyrics ? 'md:flex-[1.5]' : 'flex-1 md:max-w-xl' showLyrics ? 'lg:flex-[1.5]' : 'flex-1 lg:max-w-2xl'
}`} }`}
> >
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0"> </h3> <h3 className="section-title"> </h3>
<div className="space-y-2 sm:space-y-3 flex-1 min-h-0 overflow-y-auto custom-scrollbar"> <div className="space-y-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
<div> <div>
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold">AI </p> <p className="text-sm text-gray-400 mb-3 font-medium">AI </p>
<div className="grid grid-cols-3 gap-1.5 sm:gap-2"> <div className="grid-cols-3 gap-3" style={{ display: 'grid' }}>
{['보컬', '성우 내레이션', '배경음악'].map(type => ( {['보컬', '성우 내레이션', '배경음악'].map(type => (
<button <button
key={type} key={type}
onClick={() => setSelectedType(type)} onClick={() => setSelectedType(type)}
disabled={isGenerating} 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' : ''}`} className={`btn-select ${selectedType === type ? 'active' : ''}`}
> >
{type} {type}
</button> </button>
@ -431,8 +403,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
</div> </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> <p className="text-sm text-gray-400 mb-3 font-medium"> </p>
<div className="grid grid-cols-3 gap-1.5 sm:gap-2"> <div className="grid-cols-3 gap-3" style={{ display: 'grid' }}>
{[ {[
{ label: '한국어', flag: '🇰🇷' }, { label: '한국어', flag: '🇰🇷' },
{ label: 'English', flag: '🇺🇸' }, { label: 'English', flag: '🇺🇸' },
@ -445,9 +417,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
key={lang.label} key={lang.label}
onClick={() => setSelectedLang(lang.label)} onClick={() => setSelectedLang(lang.label)}
disabled={isGenerating} 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' : ''}`} className={`btn-select flex-col gap-1 ${selectedLang === lang.label ? 'active' : ''}`}
style={{ display: 'flex', alignItems: 'center' }}
> >
<span className="text-xs sm:text-sm">{lang.flag}</span> <span className="text-lg">{lang.flag}</span>
<span>{lang.label}</span> <span>{lang.label}</span>
</button> </button>
))} ))}
@ -455,12 +428,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
</div> </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> <p className="text-sm text-gray-400 mb-3 font-medium"> </p>
<select <select
value={selectedGenre} value={selectedGenre}
onChange={(e) => setSelectedGenre(e.target.value)} onChange={(e) => setSelectedGenre(e.target.value)}
disabled={isGenerating} 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' : ''}`} className="select-input"
> >
<option>AI </option> <option>AI </option>
<option></option> <option></option>
@ -470,17 +443,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
</div> </div>
</div> </div>
{/* Error Message */}
{errorMessage && ( {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"> <div className="error-message">
{errorMessage} {errorMessage}
</div> </div>
)} )}
{/* Status Message */}
{isGenerating && statusMessage && ( {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"> <div className="status-message">
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24"> <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/> <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"/> <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> </svg>
@ -488,19 +459,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
</div> </div>
)} )}
<div className="mt-2 sm:mt-3 shrink-0"> <div className="mt-4 shrink-0">
<button <button
onClick={handleGenerateMusic} onClick={handleGenerateMusic}
disabled={isGenerating} 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 ${ className={`btn-secondary ${isGenerating ? 'disabled' : ''}`}
isGenerating
? 'bg-[#a6ffea]/50 text-[#121a1d]/50 cursor-not-allowed'
: 'bg-[#a6ffea] text-[#121a1d] hover:bg-[#8affda]'
}`}
> >
{isGenerating ? ( {isGenerating ? (
<> <>
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24"> <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/> <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"/> <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> </svg>
@ -513,62 +480,61 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
</div> </div>
</div> </div>
{/* Right Card: Lyrics - 애니메이션으로 나타남 */} {/* Lyrics Card */}
{showLyrics && ( {showLyrics && (
<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-2xl flex flex-col flex-1 animate-slide-in overflow-hidden" className="card card-flex flex-1 animate-slide-in card-overflow-hidden min-w-280"
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> <h3 className="section-title mb-1"></h3>
<p className="text-gray-500 text-[8px] sm:text-[9px] mb-2 sm:mb-3 shrink-0"> </p> <p className="text-gray-500 text-sm mb-4 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"> <div className="card-inner flex-col gap-3 min-h-0 overflow-hidden" style={{ display: 'flex', flex: 1 }}>
{/* Interactive Player Bar */} {/* Player Bar */}
<div className="bg-black/40 rounded-full p-1.5 sm:p-2 flex items-center gap-2 shrink-0"> <div className="player-bar">
<button <button
onClick={togglePlayPause} onClick={togglePlayPause}
disabled={!audioUrl} disabled={!audioUrl}
className={`text-[#a6ffea] flex-shrink-0 transition-transform hover:scale-110 ${!audioUrl ? 'opacity-50 cursor-not-allowed' : ''}`} className={`play-btn ${!audioUrl ? 'disabled' : ''}`}
> >
{isPlaying ? ( {isPlaying ? (
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" /> <rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" /> <rect x="14" y="4" width="4" height="16" rx="1" />
</svg> </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> <svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
)} )}
</button> </button>
<div <div
ref={progressBarRef} ref={progressBarRef}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
className={`flex-1 h-1 bg-gray-800 rounded-full relative group ${audioUrl ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`} className={`progress-bar-container ${!audioUrl ? 'disabled' : ''}`}
> >
<div <div
className="absolute left-0 top-0 h-full bg-[#a6ffea] rounded-full" className="progress-bar-fill"
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
></div> ></div>
<div <div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex items-center justify-center pointer-events-none" className="progress-bar-thumb"
style={{ left: `${progress}%` }} 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="progress-bar-thumb-glow"></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 className="progress-bar-thumb-dot"></div>
</div> </div>
</div> </div>
<span className="text-[8px] sm:text-[9px] text-gray-500 font-mono w-12 sm:w-14 text-right"> <span className="time-display">
{formatTime(currentTime)} / {formatTime(duration)} {formatTime(Math.max(0, duration - currentTime))}
</span> </span>
</div> </div>
{/* Editable Lyrics */} {/* Lyrics Textarea */}
<textarea <textarea
value={lyrics} value={lyrics}
onChange={(e) => setLyrics(e.target.value)} 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" className="textarea-lyrics custom-scrollbar"
placeholder="가사가 여기에 표시됩니다..." placeholder="가사가 여기에 표시됩니다..."
/> />
</div> </div>
@ -576,7 +542,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
<button <button
onClick={handleRegenerate} onClick={handleRegenerate}
disabled={isGenerating} 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' : ''}`} className="btn-regenerate"
> >
</button> </button>
@ -585,15 +551,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
</div> </div>
</div> </div>
<div className="flex-shrink-0 flex justify-center py-2 sm:py-3 md:py-4 px-3 sm:px-4"> {/* Bottom Button */}
<div className="bottom-button-container">
<button <button
onClick={onNext} onClick={onNext}
disabled={status !== 'complete'} 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 ${ className={`btn-primary ${status !== 'complete' ? 'disabled' : ''}`}
status === 'complete'
? 'bg-[#a682ff] hover:bg-[#9570f0] text-white shadow-[#a682ff44]'
: 'bg-gray-600 text-gray-400 cursor-not-allowed shadow-none'
}`}
> >
</button> </button>

View File

@ -10,49 +10,38 @@ const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
const videoIds = ['dM8_d6Aud68', 'bb8nKmKcT0c', 'dM8_d6Aud68']; const videoIds = ['dM8_d6Aud68', 'bb8nKmKcT0c', 'dM8_d6Aud68'];
return ( return (
<div className="w-full h-full flex flex-col bg-[#121a1d] text-white relative overflow-hidden"> <div className="display-section">
<Header /> <Header />
<div className="content-safe-area pt-24 sm:pt-28"> <div className="content-safe-area" style={{ paddingTop: '7rem' }}>
{/* Main visual frames container */} {/* 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"> <div className="video-frames">
{videoIds.map((videoId, index) => ( {videoIds.map((videoId, index) => (
<div <div
key={`${videoId}-${index}`} key={`${videoId}-${index}`}
className={` className={`phone-frame ${index === 2 ? 'hidden-mobile' : ''}`}
${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 */} {/* YouTube Embed Container */}
<div className="absolute inset-0 w-full h-full pointer-events-none overflow-hidden"> <div className="phone-video-container">
<iframe <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`}
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"
title="YouTube video player" frameBorder="0"
frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen
allowFullScreen ></iframe>
></iframe> </div>
</div>
{/* Device Bezel/Frame */} {/* 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> <div className="phone-bezel"></div>
{/* Notch */} {/* 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 className="phone-notch"></div>
</div> </div>
))} ))}
</div> </div>
{/* Action Button */} {/* Action Button */}
<button <button onClick={onStartClick} className="display-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> </button>
</div> </div>

View File

@ -40,20 +40,18 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
}; };
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center bg-[#121a1d] text-white px-4 relative"> <div className="hero-section">
<div className="content-safe-area"> <div className="content-safe-area">
{/* Title */} {/* 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"> <h1 className="hero-title">CASTAD</h1>
CASTAD
</h1>
{/* Subtitle */} {/* Subtitle */}
<p className="text-sm sm:text-base md:text-xl font-light mb-12 opacity-80 text-center max-w-lg"> <p className="hero-subtitle">
Marketing Automation for Location Based Business Marketing Automation for Location Based Business
</p> </p>
{/* Input Form */} {/* Input Form */}
<div className="w-full max-w-sm flex flex-col gap-3"> <div className="hero-form">
<input <input
type="text" type="text"
value={url} value={url}
@ -62,28 +60,22 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
if (localError) setLocalError(''); if (localError) setLocalError('');
}} }}
placeholder="URL 입력" 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' : ''}`} className={`hero-input ${error ? 'error' : ''}`}
/> />
{error && ( {error && (
<p className="text-red-400 text-xs text-center">{error}</p> <p className="hero-error">{error}</p>
)} )}
<button <button onClick={handleStart} className="hero-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> </button>
</div> </div>
</div> </div>
{/* Footer Indicator - Now a button */} {/* Footer Indicator */}
<button <button onClick={onNext} className="scroll-indicator">
onClick={onNext} <span className="scroll-indicator-text"> </span>
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" <div className="scroll-indicator-icon">
>
<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 <svg
width="16" width="16"
height="16" height="16"

View File

@ -27,55 +27,37 @@ const WelcomeSection: React.FC<WelcomeSectionProps> = ({ onStartClick, onNext })
]; ];
return ( return (
<div className="w-full h-full flex flex-col bg-[#121a1d] text-white overflow-hidden"> <div className="welcome-section">
<Header /> <Header />
<div className="content-safe-area pt-20 md:pt-24"> <div className="content-safe-area" style={{ paddingTop: '5rem' }}>
<div className="mb-6 md:mb-10 text-center"> <div className="welcome-header">
<div className="inline-block mb-2 text-[#a682ff]"> <div className="welcome-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <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" /> <path d="M12 2l2.4 7.2L22 12l-7.6 2.4L12 22l-2.4-7.2L2 12l7.6-2.4z" />
</svg> </svg>
</div> </div>
<h2 className="text-xl sm:text-2xl md:text-3xl lg:text-4xl font-bold mb-2"> <h2 className="welcome-title">CASTAD .</h2>
CASTAD . <p className="welcome-subtitle">, , </p>
</h2>
<p className="text-gray-400 text-xs sm:text-sm md:text-base">
, ,
</p>
</div> </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"> <div className="feature-grid">
{features.map((feature) => ( {features.map((feature) => (
<div <div key={feature.id} className="feature-item">
key={feature.id} <div className="feature-number">{feature.id}</div>
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="feature-content">
> <h3 className="feature-title">{feature.title}</h3>
<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"> <p className="feature-description">{feature.description}</p>
{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>
))} ))}
</div> </div>
<div className="flex gap-4"> <div className="welcome-buttons">
<button <button onClick={onStartClick} className="btn-ghost">
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>
<button <button onClick={onNext} className="btn-cta">
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> </button>
</div> </div>

View File

@ -8,30 +8,22 @@ interface LoginSectionProps {
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => { const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
return ( 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"> <div className="login-container">
{/* Back Button */} {/* Back Button */}
<button <button onClick={onBack} className="login-back-btn">
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"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
</button> </button>
<div className="flex flex-col items-center text-center max-w-2xl"> <div className="login-content">
<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"> <h1 className="login-title">CASTAD</h1>
CASTAD <p className="login-subtitle">
</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 Marketing Automation for Location Based Business
</p> </p>
<button <button onClick={onLogin} className="btn-kakao">
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> </button>
</div> </div>