css 정리 .
parent
97577b6ba3
commit
ecbd81d091
29
src/App.tsx
29
src/App.tsx
|
|
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<img
|
||||||
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full overflow-hidden border border-gray-700 shadow-sm">
|
src="https://picsum.photos/seed/user/100/100"
|
||||||
<img
|
alt="User Profile"
|
||||||
src="https://picsum.photos/seed/user/100/100"
|
|
||||||
alt="User Profile"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -9,64 +9,64 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
|
||||||
const [selectedSocials, setSelectedSocials] = useState<string[]>([]);
|
const [selectedSocials, setSelectedSocials] = useState<string[]>([]);
|
||||||
|
|
||||||
const toggleSocial = (id: string) => {
|
const toggleSocial = (id: string) => {
|
||||||
setSelectedSocials(prev =>
|
setSelectedSocials(prev =>
|
||||||
prev.includes(id)
|
prev.includes(id)
|
||||||
? prev.filter(s => s !== id)
|
? prev.filter(s => s !== id)
|
||||||
: [...prev, id]
|
: [...prev, id]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,36 +60,30 @@ 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">
|
||||||
>
|
<svg
|
||||||
<span className="text-[10px] sm:text-xs font-light">더 알아보기</span>
|
width="16"
|
||||||
<div className="w-8 h-8 sm:w-10 sm:h-10 border border-white rounded-full flex items-center justify-center">
|
height="16"
|
||||||
<svg
|
viewBox="0 0 24 24"
|
||||||
width="16"
|
fill="none"
|
||||||
height="16"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
strokeWidth="2"
|
||||||
fill="none"
|
strokeLinecap="round"
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<path d="M7 13l5 5 5-5M7 6l5 5 5-5" />
|
<path d="M7 13l5 5 5-5M7 6l5 5 5-5" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue