모든 ui / ux 변경 작업 .

main
hbyang 2026-01-08 17:27:04 +09:00
parent 95928c0b66
commit f8bebdac52
9 changed files with 505 additions and 445 deletions

374
index.css
View File

@ -130,6 +130,16 @@
}
}
/* Wizard Page Container - 단계별 페이지 전환용 */
.wizard-page-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #0d1416;
overflow-y: auto;
}
/* Main Content Area */
.main-content {
flex: 1;
@ -251,23 +261,21 @@
/* Section Title (Mint color uppercase) */
.section-title {
color: var(--color-mint);
font-size: var(--text-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
color: #94FBE0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.006em;
margin-bottom: 0;
flex-shrink: 0;
}
/* Section Title Purple */
.section-title-purple {
color: var(--color-purple);
font-size: var(--text-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1.25rem;
color: #AE72F9;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.006em;
margin-bottom: 0;
display: block;
}
@ -493,19 +501,14 @@
/* Back Button Container */
.back-button-container {
width: 100%;
max-width: 1040px;
display: flex;
justify-content: flex-start;
margin-bottom: 1.5rem;
margin-left: 2.5rem;
flex-shrink: 0;
}
@media (min-width: 768px) {
.back-button-container {
margin-left: 0;
}
}
/* Bottom Button Container */
.bottom-button-container {
display: flex;
@ -776,7 +779,7 @@
.mobile-menu-btn {
position: fixed;
top: 1rem;
left: 1rem;
right: 1rem;
z-index: 40;
padding: 0.625rem;
background-color: var(--color-bg-card);
@ -1797,7 +1800,8 @@
color: var(--color-text-white);
display: flex;
flex-direction: column;
padding: var(--spacing-page);
align-items: center;
padding: 1.5rem 1rem;
background-color: var(--color-bg-dark);
overflow-y: auto;
overflow-x: hidden;
@ -1806,18 +1810,27 @@
@media (min-width: 768px) {
.analysis-container {
padding: var(--spacing-page-md);
padding: 2rem 2rem;
}
}
@media (min-width: 1024px) {
.analysis-container {
padding: 2.5rem;
padding: 2.5rem 5%;
}
}
@media (min-width: 1440px) {
.analysis-container {
/* 피그마: 1440px 화면에서 양쪽 200px 패딩 = 약 13.9% */
padding: 2.5rem calc((100% - 1040px) / 2);
}
}
/* Analysis Header */
.analysis-header {
width: 100%;
max-width: 1040px;
display: flex;
flex-direction: column;
align-items: center;
@ -1839,85 +1852,140 @@
/* Analysis Grid */
.analysis-grid {
max-width: 72rem;
margin-left: auto;
margin-right: auto;
width: 100%;
max-width: 1040px;
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: 16px;
flex-shrink: 0;
box-sizing: border-box;
}
@media (min-width: 1024px) {
@media (min-width: 768px) {
.analysis-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: 1fr 1fr;
align-items: stretch;
min-height: 453px;
}
}
/* Brand Identity Card */
.brand-identity-card {
background-color: var(--color-bg-card);
border-radius: var(--radius-3xl);
padding: var(--spacing-page);
background-color: #01393B;
border-radius: 40px;
padding: 24px;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-white-5);
box-shadow: var(--shadow-xl);
gap: 20px;
border: none;
box-shadow: none;
width: 100%;
box-sizing: border-box;
}
@media (min-width: 768px) {
.brand-identity-card {
padding: var(--spacing-page-md);
padding: 32px;
}
}
.brand-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
gap: 8px;
margin-bottom: 0;
}
.brand-name {
font-size: var(--text-3xl);
font-weight: 700;
margin-bottom: 0.5rem;
font-size: 28px;
font-weight: 600;
color: #E5F1F2;
letter-spacing: -0.006em;
margin-bottom: 0;
}
@media (min-width: 768px) {
.brand-name {
font-size: var(--text-4xl);
font-size: 32px;
}
}
.brand-location {
color: var(--color-text-gray-400);
font-size: var(--text-base);
margin-bottom: 1.5rem;
color: #6AB0B3;
font-size: 14px;
letter-spacing: -0.006em;
margin-bottom: 0;
}
.brand-subtitle {
color: #6AB0B3;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.006em;
}
.brand-info {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Report Section */
.report-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.report-toggle {
font-size: var(--text-sm);
color: var(--color-text-gray-400);
font-size: 14px;
color: #6AB0B3;
background: none;
border: none;
cursor: pointer;
transition: color var(--transition-normal);
transition: color 0.2s ease;
padding: 0;
text-align: left;
}
.report-toggle:hover {
color: var(--color-purple);
color: #AE72F9;
}
.report-content {
color: var(--color-text-gray-300);
font-size: var(--text-base);
line-height: 1.625;
color: #E5F1F2;
font-size: 17px;
line-height: 1.5;
letter-spacing: -0.006em;
overflow-y: auto;
max-height: 300px;
flex: 1;
min-height: 0;
}
.report-sections {
display: flex;
flex-direction: column;
gap: 16px;
}
.report-content::-webkit-scrollbar {
width: 6px;
}
.report-content::-webkit-scrollbar-track {
background: transparent;
}
.report-content::-webkit-scrollbar-thumb {
background: #379599;
border-radius: 3px;
}
.report-content::-webkit-scrollbar-thumb:hover {
background: #4AABAF;
}
.report-section-title {
@ -1929,18 +1997,17 @@
/* Image Preview */
.image-preview-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border-white-10);
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.image-preview-title {
color: var(--color-text-gray-400);
font-size: var(--text-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
color: #6AB0B3;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.006em;
margin-bottom: 12px;
display: block;
}
@ -1979,21 +2046,28 @@
.analysis-cards-column {
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: 16px;
width: 100%;
}
/* Feature Card (Selling Points & Keywords) */
.feature-card {
background-color: var(--color-bg-card);
border-radius: var(--radius-3xl);
padding: var(--spacing-page);
border: 1px solid var(--color-border-white-5);
box-shadow: var(--shadow-xl);
/* Feature Card for Analysis Page (Selling Points & Keywords) */
.analysis-cards-column .feature-card {
background-color: #01393B;
border-radius: 40px;
padding: 24px;
border: none;
box-shadow: none;
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
box-sizing: border-box;
}
@media (min-width: 768px) {
.feature-card {
padding: var(--spacing-page-md);
.analysis-cards-column .feature-card {
padding: 32px;
}
}
@ -2001,21 +2075,24 @@
.tags-wrapper {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
gap: 8px;
}
/* Feature Tag */
.feature-tag {
padding: 0.625rem 1.25rem;
background-color: var(--color-bg-card-inner);
border-radius: var(--radius-full);
font-size: var(--text-base);
color: #e5e7eb;
border: 1px solid var(--color-border-white-10);
padding: 8px 16px;
background-color: #046266;
border-radius: 999px;
font-size: 17px;
font-weight: 400;
color: #FFFFFF;
border: none;
}
/* Analysis Bottom Button */
.analysis-bottom {
width: 100%;
max-width: 1040px;
margin-top: 2rem;
padding-bottom: 1rem;
display: flex;
@ -2077,122 +2154,14 @@
z-index: 0;
}
.hero-orb {
/* JavaScript controlled random orbs */
.hero-orb-random {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
}
/* Purple orb - top left */
.hero-orb-purple-1 {
width: 400px;
height: 400px;
background: #AE72F9;
top: -100px;
left: 0;
animation: float-1 20s ease-in-out infinite;
}
/* Mint orb - top right */
.hero-orb-mint-1 {
width: 450px;
height: 450px;
background: #94FBE0;
top: -50px;
right: -100px;
animation: float-2 25s ease-in-out infinite;
}
/* Purple orb - bottom left */
.hero-orb-purple-2 {
width: 500px;
height: 500px;
background: #AE72F9;
bottom: -100px;
left: -100px;
animation: float-3 22s ease-in-out infinite;
}
/* Mint orb - bottom right */
.hero-orb-mint-2 {
width: 400px;
height: 400px;
background: #94FBE0;
bottom: -50px;
right: 5%;
animation: float-4 18s ease-in-out infinite;
}
@keyframes float-1 {
0% {
transform: translate(0, 0);
}
33% {
transform: translate(200px, 150px);
}
66% {
transform: translate(100px, 250px);
}
100% {
transform: translate(0, 0);
}
}
@keyframes float-2 {
0% {
transform: translate(0, 0);
}
33% {
transform: translate(-180px, 200px);
}
66% {
transform: translate(-250px, 100px);
}
100% {
transform: translate(0, 0);
}
}
@keyframes float-3 {
0% {
transform: translate(0, 0);
}
33% {
transform: translate(220px, -180px);
}
66% {
transform: translate(150px, -280px);
}
100% {
transform: translate(0, 0);
}
}
@keyframes float-4 {
0% {
transform: translate(0, 0);
}
33% {
transform: translate(-200px, -220px);
}
66% {
transform: translate(-280px, -120px);
}
100% {
transform: translate(0, 0);
}
}
.hero-bg-blur {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 200px;
background: linear-gradient(180deg, rgba(0, 34, 36, 0) 0%, rgba(0, 34, 36, 1) 100%);
opacity: 0.65;
pointer-events: none;
z-index: 2;
will-change: left, top, transform;
}
.hero-content {
@ -2606,7 +2575,7 @@
@media (min-width: 1024px) {
.feature-card {
max-width: 360px;
max-width: 500px;
min-height: 440px;
padding: 24px 40px;
gap: 24px;
@ -2997,6 +2966,14 @@
object-fit: cover;
}
.display-video {
width: 100%;
height: 100%;
border: none;
border-radius: 2.5rem;
pointer-events: none;
}
.display-frame-hidden-mobile {
display: none;
}
@ -4328,6 +4305,8 @@
font-weight: 600;
cursor: pointer;
transition: all var(--transition-normal);
width: fit-content;
flex-shrink: 0;
}
.btn-back-new:hover {
@ -4508,6 +4487,14 @@
cursor: not-allowed;
}
.sound-type-btn.permanently-disabled {
opacity: 0.35;
cursor: not-allowed;
background-color: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.4);
}
/* Genre Grid */
.genre-grid {
display: flex;
@ -4523,7 +4510,7 @@
/* Genre Button */
.genre-btn {
padding: 0.5rem 1rem;
padding: 1rem;
background-color: #002224;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
@ -5136,6 +5123,8 @@
.asset-column-left {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.asset-column-right {
@ -5164,6 +5153,7 @@
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
background-color: #002224;
border-radius: 8px;
padding: 0.5rem;

View File

@ -31,12 +31,30 @@ const App: React.FC = () => {
savedAnalysisData ? JSON.parse(savedAnalysisData) : null
);
const [error, setError] = useState<string | null>(null);
const [scrollProgress, setScrollProgress] = useState(0);
// viewMode 변경 시 localStorage에 저장
useEffect(() => {
localStorage.setItem(VIEW_MODE_KEY, viewMode);
}, [viewMode]);
// 스크롤 이벤트 핸들러 - 첫 번째 섹션에서 두 번째 섹션으로 넘어갈 때 0~1 값 계산
useEffect(() => {
const container = containerRef.current;
if (!container || viewMode !== 'landing') return;
const handleScroll = () => {
const scrollTop = container.scrollTop;
const sectionHeight = container.clientHeight;
// 첫 번째 섹션 스크롤 진행률 (0 ~ 1)
const progress = Math.min(1, Math.max(0, scrollTop / sectionHeight));
setScrollProgress(progress);
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [viewMode]);
const scrollToSection = (index: number) => {
if (containerRef.current) {
const h = containerRef.current.clientHeight;
@ -120,6 +138,7 @@ const App: React.FC = () => {
onAnalyze={handleStartAnalysis}
onNext={() => scrollToSection(1)}
error={error}
scrollProgress={scrollProgress}
/>
</section>
<section className="landing-section">

View File

@ -8,12 +8,62 @@ interface AnalysisResultSectionProps {
data: CrawlingResponse;
}
// 텍스트를 포맷팅 ([] 기준 줄바꿈, 해시태그 스타일링)
const formatReportText = (text: string): React.ReactNode[] => {
if (!text) return [];
// [제목] 패턴을 기준으로 분리
const parts = text.split(/(\[[^\]]+\])/g).filter(Boolean);
return parts.map((part, idx) => {
// [제목] 패턴인 경우
if (part.match(/^\[[^\]]+\]$/)) {
const title = part.slice(1, -1); // [] 제거
return (
<span key={idx}>
{idx > 0 && <br />}
<br />
<strong style={{ color: '#94FBE0' }}>[{title}]</strong>
<br />
</span>
);
}
// 해시태그 처리
const hashtagParts = part.split(/(#[^\s#]+)/g);
return (
<span key={idx}>
{hashtagParts.map((segment, segIdx) => {
if (segment.startsWith('#')) {
return (
<span key={segIdx} style={{ color: '#A78BFA' }}>
{segment}
</span>
);
}
return segment;
})}
</span>
);
});
};
// 마크다운 report를 섹션별로 파싱
const parseReport = (report: string) => {
if (!report || report.trim() === '') {
return [];
}
const sections: { title: string; content: string }[] = [];
const lines = report.split('\n');
let currentTitle = '';
let currentContent: string[] = [];
let hasMarkdownHeaders = report.includes('## ');
// 마크다운 헤더가 없는 경우 전체 텍스트를 하나의 섹션으로 반환
if (!hasMarkdownHeaders) {
return [{ title: '분석 결과', content: report.trim() }];
}
lines.forEach((line) => {
if (line.startsWith('## ')) {
@ -31,11 +81,11 @@ const parseReport = (report: string) => {
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
}
return sections.filter(s => s.title && s.content && !s.title.includes('JSON'));
return sections.filter(s => s.content && !s.title.includes('JSON'));
};
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
const { processed_info, marketing_analysis, image_list } = data;
const { processed_info, marketing_analysis } = data;
const tags = marketing_analysis.tags || [];
const facilities = marketing_analysis.facilities || [];
const [showFullReport, setShowFullReport] = useState(false);
@ -71,57 +121,53 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
{/* Brand Identity */}
<div className="brand-identity-card">
<div className="brand-header">
<span className="section-title mb-0"> </span>
<span className="text-gray-500 text-sm">AI </span>
<span className="section-title"> </span>
<span className="brand-subtitle">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>
<div className="brand-info">
<h2 className="brand-name">{processed_info.customer_name}</h2>
<p className="brand-location">{processed_info.region} · {processed_info.detail_region_info}</p>
</div>
{/* 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-section">
<button
onClick={() => setShowFullReport(!showFullReport)}
className="report-toggle"
>
{showFullReport ? '간략히 보기' : '자세히 보기'}
</button>
<div className="report-content custom-scrollbar">
{showFullReport ? (
<div className="space-y-4">
{reportSections.length === 0 ? (
<div>
{marketing_analysis.report
? formatReportText(marketing_analysis.report)
: '분석 결과가 없습니다.'}
</div>
) : showFullReport ? (
<div className="report-sections">
{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>
{section.title && <h4 className="report-section-title">{section.title}</h4>}
<div>
{formatReportText(section.content)}
</div>
</div>
))}
</div>
) : (
<p className="text-gray-300">
{reportSections[0]?.content.slice(0, 150)}...
</p>
<div>
{formatReportText(
reportSections[0]?.content.length > 150
? `${reportSections[0].content.slice(0, 150)}...`
: reportSections[0]?.content || ''
)}
</div>
)}
</div>
</div>
{/* Image Preview */}
{image_list.length > 0 && (
<div className="image-preview-section">
<span className="image-preview-title"> ({image_list.length})</span>
<div className="image-preview-grid">
{image_list.slice(0, 8).map((img, idx) => (
<div key={idx} className="image-preview-item">
<img src={img} alt={`이미지 ${idx + 1}`} />
</div>
))}
</div>
{image_list.length > 8 && (
<p className="image-preview-more">+{image_list.length - 8} </p>
)}
</div>
)}
</div>
{/* Right Cards */}

View File

@ -4,7 +4,6 @@ import { ImageItem, ImageUrlItem } from '../../types/api';
import { uploadImages } from '../../utils/api';
interface AssetManagementContentProps {
onBack: () => void;
onNext: (imageTaskId: string) => void;
imageList: ImageItem[];
onRemoveImage: (index: number) => void;
@ -14,7 +13,6 @@ interface AssetManagementContentProps {
type VideoRatio = 'vertical' | 'horizontal';
const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
onBack,
onNext,
imageList,
onRemoveImage,
@ -74,20 +72,8 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
};
const handleImageListWheel = (e: React.WheelEvent) => {
const el = imageListRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const hasScrollableContent = scrollHeight > clientHeight;
if (!hasScrollableContent) return;
const atTop = scrollTop <= 0;
const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
e.stopPropagation();
}
// 이 영역 안에서는 항상 스크롤 이벤트 전파 차단
e.stopPropagation();
};
const handleDragOver = (e: React.DragEvent) => {
@ -122,30 +108,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
return (
<main className="page-container">
{/* Header with Back Button and Progress */}
<div className="asset-header">
<button onClick={onBack} className="btn-back-new">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
<span></span>
</button>
<div className="progress-indicator">
<div className="progress-label">
<span className="progress-text"></span>
<div className="progress-numbers">
<span className="progress-current">1</span>
<span className="progress-divider">/</span>
<span className="progress-total">2</span>
</div>
</div>
<div className="progress-bar-wrapper">
<div className="progress-bar-fill" style={{ width: '50%' }}></div>
</div>
</div>
</div>
{/* Title */}
<h1 className="asset-title"> </h1>
@ -158,7 +120,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
ref={imageListRef}
onWheel={handleImageListWheel}
className="asset-image-list"
style={{ overscrollBehavior: 'contain' }}
>
{imageList.length > 0 ? (
<div className="asset-image-grid">

View File

@ -282,7 +282,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
return (
<main className="page-container">
{/* Header with Back Button and Progress */}
{/* Header with Back Button */}
<div className="asset-header">
<button onClick={onBack} className="btn-back-new">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@ -290,23 +290,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</svg>
<span></span>
</button>
<div className="progress-indicator">
<div className="progress-label">
<span className="progress-text"></span>
<div className="progress-numbers">
<span className="progress-current">2</span>
<span className="progress-divider">/</span>
<span className="progress-total">2</span>
</div>
</div>
<div className="progress-bar-wrapper">
<div
className="progress-bar-fill"
style={{ width: videoStatus === 'complete' ? '100%' : `${renderProgress}%` }}
></div>
</div>
</div>
</div>
{/* Title */}

View File

@ -1,5 +1,5 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import Sidebar from '../../components/Sidebar';
import AssetManagementContent from './AssetManagementContent';
import SoundStudioContent from './SoundStudioContent';
@ -40,8 +40,6 @@ interface GenerationFlowProps {
}
const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveItem = '대시보드', initialImageList = [], businessInfo }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
// localStorage에서 저장된 상태 복원
const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY);
const savedWizardStep = localStorage.getItem(WIZARD_STEP_KEY);
@ -49,7 +47,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
const savedImageTaskId = localStorage.getItem(IMAGE_TASK_ID_KEY);
const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem);
const [maxWizardIndex, setMaxWizardIndex] = useState(savedWizardStep ? parseInt(savedWizardStep, 10) : 0);
// 현재 위저드 단계 (0: Asset, 1: Sound Studio, 2: Completion)
const [wizardStep, setWizardStep] = useState(savedWizardStep ? parseInt(savedWizardStep, 10) : 0);
const [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId);
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
@ -84,22 +83,17 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동
const handleHome = () => {
clearAllProjectStorage();
setMaxWizardIndex(0);
setWizardStep(0);
setSongTaskId(null);
setImageTaskId(null);
setImageList(initialImageList.map(url => ({ type: 'url', url })));
onHome();
};
const scrollToWizardSection = (index: number) => {
if (scrollContainerRef.current) {
const sections = scrollContainerRef.current.querySelectorAll('.flow-section');
if (sections[index]) {
setMaxWizardIndex(prev => Math.max(prev, index));
localStorage.setItem(WIZARD_STEP_KEY, index.toString());
sections[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// 위저드 단계 이동
const goToWizardStep = (step: number) => {
setWizardStep(step);
localStorage.setItem(WIZARD_STEP_KEY, step.toString());
};
// activeItem 변경 시 localStorage에 저장
@ -107,60 +101,55 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
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);
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
const renderWizardContent = () => {
switch (wizardStep) {
case 0:
return (
<AssetManagementContent
onNext={(taskId: string) => {
// Clear video generation state to start fresh
localStorage.removeItem(VIDEO_GENERATION_KEY);
setVideoGenerationStatus('idle');
setVideoGenerationProgress(0);
setImageTaskId(taskId);
localStorage.setItem(IMAGE_TASK_ID_KEY, taskId);
goToWizardStep(1);
}}
imageList={imageList}
onRemoveImage={handleRemoveImage}
onAddImages={handleAddImages}
/>
);
case 1:
return (
<SoundStudioContent
onBack={() => goToWizardStep(0)}
onNext={(taskId: string) => {
setSongTaskId(taskId);
localStorage.setItem(SONG_TASK_ID_KEY, taskId);
goToWizardStep(2);
}}
businessInfo={businessInfo}
imageTaskId={imageTaskId}
videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress}
/>
);
case 2:
return (
<CompletionContent
onBack={() => goToWizardStep(1)}
songTaskId={songTaskId}
onVideoStatusChange={setVideoGenerationStatus}
onVideoProgressChange={setVideoGenerationProgress}
/>
);
default:
return null;
}
}, []);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container || activeItem !== '새 프로젝트 만들기') return;
const handleWheel = (e: WheelEvent) => {
// 스크롤 가능한 자식 요소 내부에서 발생한 이벤트인지 확인
const target = e.target as HTMLElement;
// custom-scrollbar 클래스를 가진 요소 또는 그 자식에서 발생한 이벤트인지 확인
const scrollableParent = target.closest('.custom-scrollbar') as HTMLElement;
if (scrollableParent) {
const { scrollTop, scrollHeight, clientHeight } = scrollableParent;
const hasScrollableContent = scrollHeight > clientHeight;
const atTop = scrollTop <= 0;
const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
// 스크롤 가능한 콘텐츠가 있는 경우
if (hasScrollableContent) {
// 위로 스크롤하고 맨 위가 아니거나, 아래로 스크롤하고 맨 아래가 아니면 허용
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
return; // 이미지 리스트 스크롤 허용 (preventDefault 하지 않음)
}
}
}
const h = container.clientHeight;
const currentIdx = Math.round(container.scrollTop / h);
if (e.deltaY > 0) { // Down
if (currentIdx >= maxWizardIndex) {
e.preventDefault();
}
}
};
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}, [maxWizardIndex, activeItem]);
};
const renderContent = () => {
switch (activeItem) {
@ -170,54 +159,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
return <BusinessSettingsContent />;
case '새 프로젝트 만들기':
return (
<div
ref={scrollContainerRef}
className="flex-1 h-full overflow-y-auto snap-y snap-mandatory scroll-smooth no-scrollbar"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{/* Step 0: Asset Management (AnalysisResultSection removed) */}
<div className="flow-section snap-start h-full w-full flex flex-col overflow-hidden">
<AssetManagementContent
onBack={() => setActiveItem('대시보드')}
onNext={(taskId: string) => {
// Clear video generation state to start fresh
localStorage.removeItem(VIDEO_GENERATION_KEY);
setVideoGenerationStatus('idle');
setVideoGenerationProgress(0);
setImageTaskId(taskId);
localStorage.setItem(IMAGE_TASK_ID_KEY, taskId);
scrollToWizardSection(1);
}}
imageList={imageList}
onRemoveImage={handleRemoveImage}
onAddImages={handleAddImages}
/>
</div>
{/* Step 1: Sound Studio */}
<div className="flow-section snap-start h-full w-full flex flex-col">
<SoundStudioContent
onBack={() => scrollToWizardSection(0)}
onNext={(taskId: string) => {
setSongTaskId(taskId);
localStorage.setItem(SONG_TASK_ID_KEY, taskId);
scrollToWizardSection(2);
}}
businessInfo={businessInfo}
imageTaskId={imageTaskId}
videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress}
/>
</div>
{/* Step 2: Completion */}
<div className="flow-section snap-start h-full w-full flex flex-col overflow-hidden">
<CompletionContent
onBack={() => scrollToWizardSection(1)}
songTaskId={songTaskId}
onVideoStatusChange={setVideoGenerationStatus}
onVideoProgressChange={setVideoGenerationProgress}
/>
</div>
<div className="wizard-page-container">
{renderWizardContent()}
</div>
);
default:

View File

@ -430,7 +430,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<audio ref={audioRef} src={audioUrl} preload="metadata" />
)}
{/* Header with Progress */}
{/* Header */}
<div className="sound-studio-header">
<button onClick={onBack} className="btn-back-new">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@ -438,19 +438,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</svg>
</button>
<div className="progress-indicator">
<div className="progress-label">
<span className="progress-text"></span>
<div className="progress-numbers">
<span className="progress-current">2</span>
<span className="progress-divider">/</span>
<span className="progress-total">2</span>
</div>
</div>
<div className="progress-bar-wrapper">
<div className="progress-bar-track"></div>
</div>
</div>
</div>
{/* Page Title */}
@ -467,16 +454,19 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<div className="sound-studio-section">
<label className="input-label">AI </label>
<div className="sound-type-grid">
{['보컬', '성우 내레이션', '배경음악'].map(type => (
<button
key={type}
onClick={() => setSelectedType(type)}
disabled={isGenerating}
className={`sound-type-btn ${selectedType === type ? 'active' : ''}`}
>
{type}
</button>
))}
{['보컬', '배경음악', '성우 내레이션'].map(type => {
const isDisabled = type === '성우 내레이션' || isGenerating;
return (
<button
key={type}
onClick={() => !isDisabled && setSelectedType(type)}
disabled={isDisabled}
className={`sound-type-btn ${selectedType === type ? 'active' : ''} ${type === '성우 내레이션' ? 'permanently-disabled' : ''}`}
>
{type}
</button>
);
})}
</div>
</div>

View File

@ -7,10 +7,11 @@ interface DisplaySectionProps {
}
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
const frames = [
{ id: 1, image: '/assets/images/display-frame-1.png' },
{ id: 2, image: '/assets/images/display-frame-1.png' },
{ id: 3, image: '/assets/images/display-frame-1.png' },
// YouTube Shorts 영상 ID들
const videos = [
{ id: 1, videoId: 'OZJ8X4P82OA' },
{ id: 2, videoId: 'hNzMO21O40c' },
{ id: 3, videoId: 'dM8_d6Aud68' },
];
return (
@ -18,12 +19,19 @@ const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
<div className="content-safe-area">
{/* Main visual frames container */}
<div className="display-frames">
{frames.map((frame, index) => (
{videos.map((video, index) => (
<div
key={frame.id}
key={video.id}
className={`display-frame ${index === 2 ? 'display-frame-hidden-mobile' : ''}`}
>
<img src={frame.image} alt={`Display ${frame.id}`} />
<iframe
src={`https://www.youtube.com/embed/${video.videoId}?autoplay=1&mute=1&loop=1&playlist=${video.videoId}&controls=0&showinfo=0&rel=0&modestbranding=1&playsinline=1`}
title={`YouTube Shorts ${video.id}`}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="display-video"
/>
</div>
))}
</div>

View File

@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
interface HeroSectionProps {
onAnalyze?: (url: string) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number; // 0 ~ 1 (스크롤 진행률)
}
const isValidUrl = (string: string): boolean => {
@ -16,9 +17,117 @@ const isValidUrl = (string: string): boolean => {
}
};
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: externalError }) => {
// Orb configuration with movement zones to prevent overlap
interface OrbConfig {
id: string;
size: number;
initialX: number;
initialY: number;
color: string;
// Movement bounds for each orb
minX: number;
maxX: number;
minY: number;
maxY: number;
}
// 6 orbs distributed in a 3x2 grid pattern with overlapping zones for smooth movement
const orbConfigs: OrbConfig[] = [
// Top-left zone
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
// Top-right zone
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
// Middle-left zone
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
// Middle-right zone
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
// Bottom-left zone
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
// Bottom-right zone
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
];
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: externalError, scrollProgress = 0 }) => {
const [url, setUrl] = useState('');
const [localError, setLocalError] = useState('');
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]);
// Random movement for orbs
useEffect(() => {
const moveOrb = (orb: HTMLDivElement, index: number) => {
const config = orbConfigs[index];
let currentX = config.initialX;
let currentY = config.initialY;
// 초기 타겟은 현재 위치와 동일하게 설정 (순간이동 방지)
let targetX = currentX;
let targetY = currentY;
let scale = 1;
let targetScale = 1;
let isFirstMove = true;
const generateNewTarget = () => {
// Move within the orb's designated zone
const rangeX = config.maxX - config.minX;
const rangeY = config.maxY - config.minY;
if (isFirstMove) {
// 첫 이동은 현재 위치에서 가까운 곳으로 (자연스러운 시작)
const smallRangeX = rangeX * 0.3;
const smallRangeY = rangeY * 0.3;
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
// 범위 내로 클램핑
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
isFirstMove = false;
} else {
targetX = config.minX + Math.random() * rangeX;
targetY = config.minY + Math.random() * rangeY;
}
targetScale = 0.9 + Math.random() * 0.2; // 0.9 to 1.1 (더 작은 범위)
};
const animate = () => {
// Slow speed - 일정한 속도로 부드럽게
const speed = 0.003;
currentX += (targetX - currentX) * speed;
currentY += (targetY - currentY) * speed;
scale += (targetScale - scale) * speed;
// Apply transform
orb.style.left = `${currentX}%`;
orb.style.top = `${currentY}%`;
orb.style.transform = `scale(${scale})`;
// Generate new target when close enough
const distance = Math.sqrt(
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)
);
if (distance < 1) {
generateNewTarget();
}
animationRefs.current[index] = requestAnimationFrame(animate);
};
// 첫 타겟 생성 후 애니메이션 시작
generateNewTarget();
animate();
};
// 모든 공이 동시에 자연스럽게 시작 (딜레이 없이)
orbRefs.current.forEach((orb, index) => {
if (orb) {
moveOrb(orb, index);
}
});
// Cleanup
return () => {
animationRefs.current.forEach(id => cancelAnimationFrame(id));
};
}, []);
const error = externalError || localError;
@ -41,15 +150,26 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
return (
<div className="hero-section">
{/* Animated background orbs */}
<div className="hero-bg-orbs">
<div className="hero-orb hero-orb-purple-1"></div>
<div className="hero-orb hero-orb-mint-1"></div>
<div className="hero-orb hero-orb-purple-2"></div>
<div className="hero-orb hero-orb-mint-2"></div>
{/* Animated background orbs - 스크롤에 따라 빠르게 사라짐 */}
<div
className="hero-bg-orbs"
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
>
{orbConfigs.map((config, index) => (
<div
key={config.id}
ref={el => { orbRefs.current[index] = el; }}
className="hero-orb-random"
style={{
width: `${config.size}px`,
height: `${config.size}px`,
background: config.color,
left: `${config.initialX}%`,
top: `${config.initialY}%`,
}}
/>
))}
</div>
{/* Background blur overlay */}
<div className="hero-bg-blur"></div>
<div className="hero-content">
{/* Logo Image */}