모든 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 Area */
.main-content { .main-content {
flex: 1; flex: 1;
@ -251,23 +261,21 @@
/* Section Title (Mint color uppercase) */ /* Section Title (Mint color uppercase) */
.section-title { .section-title {
color: var(--color-mint); color: #94FBE0;
font-size: var(--text-sm); font-size: 16px;
font-weight: 700; font-weight: 600;
text-transform: uppercase; letter-spacing: -0.006em;
letter-spacing: 0.05em; margin-bottom: 0;
margin-bottom: 1rem;
flex-shrink: 0; flex-shrink: 0;
} }
/* Section Title Purple */ /* Section Title Purple */
.section-title-purple { .section-title-purple {
color: var(--color-purple); color: #AE72F9;
font-size: var(--text-sm); font-size: 16px;
font-weight: 700; font-weight: 600;
text-transform: uppercase; letter-spacing: -0.006em;
letter-spacing: 0.05em; margin-bottom: 0;
margin-bottom: 1.25rem;
display: block; display: block;
} }
@ -493,19 +501,14 @@
/* Back Button Container */ /* Back Button Container */
.back-button-container { .back-button-container {
width: 100%;
max-width: 1040px;
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
margin-left: 2.5rem;
flex-shrink: 0; flex-shrink: 0;
} }
@media (min-width: 768px) {
.back-button-container {
margin-left: 0;
}
}
/* Bottom Button Container */ /* Bottom Button Container */
.bottom-button-container { .bottom-button-container {
display: flex; display: flex;
@ -776,7 +779,7 @@
.mobile-menu-btn { .mobile-menu-btn {
position: fixed; position: fixed;
top: 1rem; top: 1rem;
left: 1rem; right: 1rem;
z-index: 40; z-index: 40;
padding: 0.625rem; padding: 0.625rem;
background-color: var(--color-bg-card); background-color: var(--color-bg-card);
@ -1797,7 +1800,8 @@
color: var(--color-text-white); color: var(--color-text-white);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--spacing-page); align-items: center;
padding: 1.5rem 1rem;
background-color: var(--color-bg-dark); background-color: var(--color-bg-dark);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@ -1806,18 +1810,27 @@
@media (min-width: 768px) { @media (min-width: 768px) {
.analysis-container { .analysis-container {
padding: var(--spacing-page-md); padding: 2rem 2rem;
} }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.analysis-container { .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 */
.analysis-header { .analysis-header {
width: 100%;
max-width: 1040px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -1839,85 +1852,140 @@
/* Analysis Grid */ /* Analysis Grid */
.analysis-grid { .analysis-grid {
max-width: 72rem;
margin-left: auto;
margin-right: auto;
width: 100%; width: 100%;
max-width: 1040px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 16px;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box;
} }
@media (min-width: 1024px) { @media (min-width: 768px) {
.analysis-grid { .analysis-grid {
display: 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 */
.brand-identity-card { .brand-identity-card {
background-color: var(--color-bg-card); background-color: #01393B;
border-radius: var(--radius-3xl); border-radius: 40px;
padding: var(--spacing-page); padding: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--color-border-white-5); gap: 20px;
box-shadow: var(--shadow-xl); border: none;
box-shadow: none;
width: 100%;
box-sizing: border-box;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.brand-identity-card { .brand-identity-card {
padding: var(--spacing-page-md); padding: 32px;
} }
} }
.brand-header { .brand-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 8px;
margin-bottom: 1rem; margin-bottom: 0;
} }
.brand-name { .brand-name {
font-size: var(--text-3xl); font-size: 28px;
font-weight: 700; font-weight: 600;
margin-bottom: 0.5rem; color: #E5F1F2;
letter-spacing: -0.006em;
margin-bottom: 0;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.brand-name { .brand-name {
font-size: var(--text-4xl); font-size: 32px;
} }
} }
.brand-location { .brand-location {
color: var(--color-text-gray-400); color: #6AB0B3;
font-size: var(--text-base); font-size: 14px;
margin-bottom: 1.5rem; 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 */
.report-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.report-toggle { .report-toggle {
font-size: var(--text-sm); font-size: 14px;
color: var(--color-text-gray-400); color: #6AB0B3;
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: color var(--transition-normal); transition: color 0.2s ease;
padding: 0;
text-align: left;
} }
.report-toggle:hover { .report-toggle:hover {
color: var(--color-purple); color: #AE72F9;
} }
.report-content { .report-content {
color: var(--color-text-gray-300); color: #E5F1F2;
font-size: var(--text-base); font-size: 17px;
line-height: 1.625; line-height: 1.5;
letter-spacing: -0.006em;
overflow-y: auto; 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 { .report-section-title {
@ -1929,18 +1997,17 @@
/* Image Preview */ /* Image Preview */
.image-preview-section { .image-preview-section {
margin-top: 1.5rem; margin-top: auto;
padding-top: 1.5rem; padding-top: 1rem;
border-top: 1px solid var(--color-border-white-10); border-top: 1px solid rgba(255, 255, 255, 0.1);
} }
.image-preview-title { .image-preview-title {
color: var(--color-text-gray-400); color: #6AB0B3;
font-size: var(--text-sm); font-size: 14px;
font-weight: 700; font-weight: 600;
text-transform: uppercase; letter-spacing: -0.006em;
letter-spacing: 0.05em; margin-bottom: 12px;
margin-bottom: 0.75rem;
display: block; display: block;
} }
@ -1979,21 +2046,28 @@
.analysis-cards-column { .analysis-cards-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 16px;
width: 100%;
} }
/* Feature Card (Selling Points & Keywords) */ /* Feature Card for Analysis Page (Selling Points & Keywords) */
.feature-card { .analysis-cards-column .feature-card {
background-color: var(--color-bg-card); background-color: #01393B;
border-radius: var(--radius-3xl); border-radius: 40px;
padding: var(--spacing-page); padding: 24px;
border: 1px solid var(--color-border-white-5); border: none;
box-shadow: var(--shadow-xl); box-shadow: none;
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
box-sizing: border-box;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.feature-card { .analysis-cards-column .feature-card {
padding: var(--spacing-page-md); padding: 32px;
} }
} }
@ -2001,21 +2075,24 @@
.tags-wrapper { .tags-wrapper {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 8px;
} }
/* Feature Tag */ /* Feature Tag */
.feature-tag { .feature-tag {
padding: 0.625rem 1.25rem; padding: 8px 16px;
background-color: var(--color-bg-card-inner); background-color: #046266;
border-radius: var(--radius-full); border-radius: 999px;
font-size: var(--text-base); font-size: 17px;
color: #e5e7eb; font-weight: 400;
border: 1px solid var(--color-border-white-10); color: #FFFFFF;
border: none;
} }
/* Analysis Bottom Button */ /* Analysis Bottom Button */
.analysis-bottom { .analysis-bottom {
width: 100%;
max-width: 1040px;
margin-top: 2rem; margin-top: 2rem;
padding-bottom: 1rem; padding-bottom: 1rem;
display: flex; display: flex;
@ -2077,122 +2154,14 @@
z-index: 0; z-index: 0;
} }
.hero-orb { /* JavaScript controlled random orbs */
.hero-orb-random {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
filter: blur(80px); filter: blur(80px);
opacity: 0.6; opacity: 0.65;
}
/* 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%);
pointer-events: none; pointer-events: none;
z-index: 2; will-change: left, top, transform;
} }
.hero-content { .hero-content {
@ -2606,7 +2575,7 @@
@media (min-width: 1024px) { @media (min-width: 1024px) {
.feature-card { .feature-card {
max-width: 360px; max-width: 500px;
min-height: 440px; min-height: 440px;
padding: 24px 40px; padding: 24px 40px;
gap: 24px; gap: 24px;
@ -2997,6 +2966,14 @@
object-fit: cover; object-fit: cover;
} }
.display-video {
width: 100%;
height: 100%;
border: none;
border-radius: 2.5rem;
pointer-events: none;
}
.display-frame-hidden-mobile { .display-frame-hidden-mobile {
display: none; display: none;
} }
@ -4328,6 +4305,8 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all var(--transition-normal); transition: all var(--transition-normal);
width: fit-content;
flex-shrink: 0;
} }
.btn-back-new:hover { .btn-back-new:hover {
@ -4508,6 +4487,14 @@
cursor: not-allowed; 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 */
.genre-grid { .genre-grid {
display: flex; display: flex;
@ -4523,7 +4510,7 @@
/* Genre Button */ /* Genre Button */
.genre-btn { .genre-btn {
padding: 0.5rem 1rem; padding: 1rem;
background-color: #002224; background-color: #002224;
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px; border-radius: 8px;
@ -5136,6 +5123,8 @@
.asset-column-left { .asset-column-left {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
min-height: 0;
overflow: hidden;
} }
.asset-column-right { .asset-column-right {
@ -5164,6 +5153,7 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
background-color: #002224; background-color: #002224;
border-radius: 8px; border-radius: 8px;
padding: 0.5rem; padding: 0.5rem;

View File

@ -31,12 +31,30 @@ const App: React.FC = () => {
savedAnalysisData ? JSON.parse(savedAnalysisData) : null savedAnalysisData ? JSON.parse(savedAnalysisData) : null
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [scrollProgress, setScrollProgress] = useState(0);
// viewMode 변경 시 localStorage에 저장 // viewMode 변경 시 localStorage에 저장
useEffect(() => { useEffect(() => {
localStorage.setItem(VIEW_MODE_KEY, viewMode); localStorage.setItem(VIEW_MODE_KEY, viewMode);
}, [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) => { const scrollToSection = (index: number) => {
if (containerRef.current) { if (containerRef.current) {
const h = containerRef.current.clientHeight; const h = containerRef.current.clientHeight;
@ -120,6 +138,7 @@ const App: React.FC = () => {
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onNext={() => scrollToSection(1)} onNext={() => scrollToSection(1)}
error={error} error={error}
scrollProgress={scrollProgress}
/> />
</section> </section>
<section className="landing-section"> <section className="landing-section">

View File

@ -8,12 +8,62 @@ interface AnalysisResultSectionProps {
data: CrawlingResponse; 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를 섹션별로 파싱 // 마크다운 report를 섹션별로 파싱
const parseReport = (report: string) => { const parseReport = (report: string) => {
if (!report || report.trim() === '') {
return [];
}
const sections: { title: string; content: string }[] = []; const sections: { title: string; content: string }[] = [];
const lines = report.split('\n'); const lines = report.split('\n');
let currentTitle = ''; let currentTitle = '';
let currentContent: string[] = []; let currentContent: string[] = [];
let hasMarkdownHeaders = report.includes('## ');
// 마크다운 헤더가 없는 경우 전체 텍스트를 하나의 섹션으로 반환
if (!hasMarkdownHeaders) {
return [{ title: '분석 결과', content: report.trim() }];
}
lines.forEach((line) => { lines.forEach((line) => {
if (line.startsWith('## ')) { if (line.startsWith('## ')) {
@ -31,11 +81,11 @@ const parseReport = (report: string) => {
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() }); 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 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 tags = marketing_analysis.tags || [];
const facilities = marketing_analysis.facilities || []; const facilities = marketing_analysis.facilities || [];
const [showFullReport, setShowFullReport] = useState(false); const [showFullReport, setShowFullReport] = useState(false);
@ -71,57 +121,53 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
{/* Brand Identity */} {/* Brand Identity */}
<div className="brand-identity-card"> <div className="brand-identity-card">
<div className="brand-header"> <div className="brand-header">
<span className="section-title mb-0"> </span> <span className="section-title"> </span>
<span className="text-gray-500 text-sm">AI </span> <span className="brand-subtitle">AI </span>
</div> </div>
<div className="brand-info">
<h2 className="brand-name">{processed_info.customer_name}</h2> <h2 className="brand-name">{processed_info.customer_name}</h2>
<p className="brand-location">{processed_info.region} · {processed_info.detail_region_info}</p> <p className="brand-location">{processed_info.region} · {processed_info.detail_region_info}</p>
</div>
{/* Marketing Analysis Summary */} {/* Marketing Analysis Summary */}
<div className="flex-1"> <div className="report-section">
<div className="flex-between mb-3">
<button <button
onClick={() => setShowFullReport(!showFullReport)} onClick={() => setShowFullReport(!showFullReport)}
className="report-toggle" className="report-toggle"
> >
{showFullReport ? '간략히 보기' : '자세히 보기'} {showFullReport ? '간략히 보기' : '자세히 보기'}
</button> </button>
</div>
<div className="report-content custom-scrollbar"> <div className="report-content custom-scrollbar">
{showFullReport ? ( {reportSections.length === 0 ? (
<div className="space-y-4"> <div>
{marketing_analysis.report
? formatReportText(marketing_analysis.report)
: '분석 결과가 없습니다.'}
</div>
) : showFullReport ? (
<div className="report-sections">
{reportSections.map((section, idx) => ( {reportSections.map((section, idx) => (
<div key={idx}> <div key={idx}>
<h4 className="report-section-title">{section.title}</h4> {section.title && <h4 className="report-section-title">{section.title}</h4>}
<p className="text-gray-300" style={{ lineHeight: 1.625 }}>{section.content}</p> <div>
{formatReportText(section.content)}
</div>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-gray-300"> <div>
{reportSections[0]?.content.slice(0, 150)}... {formatReportText(
</p> reportSections[0]?.content.length > 150
? `${reportSections[0].content.slice(0, 150)}...`
: reportSections[0]?.content || ''
)}
</div>
)} )}
</div> </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> </div>
{/* Right Cards */} {/* Right Cards */}

View File

@ -4,7 +4,6 @@ import { ImageItem, ImageUrlItem } from '../../types/api';
import { uploadImages } from '../../utils/api'; import { uploadImages } from '../../utils/api';
interface AssetManagementContentProps { interface AssetManagementContentProps {
onBack: () => void;
onNext: (imageTaskId: string) => void; onNext: (imageTaskId: string) => void;
imageList: ImageItem[]; imageList: ImageItem[];
onRemoveImage: (index: number) => void; onRemoveImage: (index: number) => void;
@ -14,7 +13,6 @@ interface AssetManagementContentProps {
type VideoRatio = 'vertical' | 'horizontal'; type VideoRatio = 'vertical' | 'horizontal';
const AssetManagementContent: React.FC<AssetManagementContentProps> = ({ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
onBack,
onNext, onNext,
imageList, imageList,
onRemoveImage, onRemoveImage,
@ -74,20 +72,8 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}; };
const handleImageListWheel = (e: React.WheelEvent) => { 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) => { const handleDragOver = (e: React.DragEvent) => {
@ -122,30 +108,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
return ( return (
<main className="page-container"> <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 */} {/* Title */}
<h1 className="asset-title"> </h1> <h1 className="asset-title"> </h1>
@ -158,7 +120,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
ref={imageListRef} ref={imageListRef}
onWheel={handleImageListWheel} onWheel={handleImageListWheel}
className="asset-image-list" className="asset-image-list"
style={{ overscrollBehavior: 'contain' }}
> >
{imageList.length > 0 ? ( {imageList.length > 0 ? (
<div className="asset-image-grid"> <div className="asset-image-grid">

View File

@ -282,7 +282,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
return ( return (
<main className="page-container"> <main className="page-container">
{/* Header with Back Button and Progress */} {/* Header with Back Button */}
<div className="asset-header"> <div className="asset-header">
<button onClick={onBack} className="btn-back-new"> <button onClick={onBack} className="btn-back-new">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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> </svg>
<span></span> <span></span>
</button> </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> </div>
{/* Title */} {/* 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 Sidebar from '../../components/Sidebar';
import AssetManagementContent from './AssetManagementContent'; import AssetManagementContent from './AssetManagementContent';
import SoundStudioContent from './SoundStudioContent'; import SoundStudioContent from './SoundStudioContent';
@ -40,8 +40,6 @@ 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);
// localStorage에서 저장된 상태 복원 // localStorage에서 저장된 상태 복원
const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY); const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY);
const savedWizardStep = localStorage.getItem(WIZARD_STEP_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 savedImageTaskId = localStorage.getItem(IMAGE_TASK_ID_KEY);
const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem); 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 [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId);
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId); const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle'); const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
@ -84,22 +83,17 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동 // 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동
const handleHome = () => { const handleHome = () => {
clearAllProjectStorage(); clearAllProjectStorage();
setMaxWizardIndex(0); setWizardStep(0);
setSongTaskId(null); setSongTaskId(null);
setImageTaskId(null); setImageTaskId(null);
setImageList(initialImageList.map(url => ({ type: 'url', url }))); setImageList(initialImageList.map(url => ({ type: 'url', url })));
onHome(); onHome();
}; };
const scrollToWizardSection = (index: number) => { // 위저드 단계 이동
if (scrollContainerRef.current) { const goToWizardStep = (step: number) => {
const sections = scrollContainerRef.current.querySelectorAll('.flow-section'); setWizardStep(step);
if (sections[index]) { localStorage.setItem(WIZARD_STEP_KEY, step.toString());
setMaxWizardIndex(prev => Math.max(prev, index));
localStorage.setItem(WIZARD_STEP_KEY, index.toString());
sections[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}; };
// activeItem 변경 시 localStorage에 저장 // activeItem 변경 시 localStorage에 저장
@ -107,61 +101,56 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem); localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
}, [activeItem]); }, [activeItem]);
// 새로고침 시 저장된 위저드 단계로 스크롤 // 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
useEffect(() => { const renderWizardContent = () => {
if (activeItem === '새 프로젝트 만들기' && savedWizardStep) { switch (wizardStep) {
const stepIndex = parseInt(savedWizardStep, 10); case 0:
// 약간의 딜레이 후 스크롤 (DOM이 준비될 때까지) return (
setTimeout(() => { <AssetManagementContent
if (scrollContainerRef.current) { onNext={(taskId: string) => {
const sections = scrollContainerRef.current.querySelectorAll('.flow-section'); // Clear video generation state to start fresh
if (sections[stepIndex]) { localStorage.removeItem(VIDEO_GENERATION_KEY);
sections[stepIndex].scrollIntoView({ behavior: 'auto', block: 'start' }); setVideoGenerationStatus('idle');
} setVideoGenerationProgress(0);
}
}, 100);
}
}, []);
useEffect(() => { setImageTaskId(taskId);
const container = scrollContainerRef.current; localStorage.setItem(IMAGE_TASK_ID_KEY, taskId);
if (!container || activeItem !== '새 프로젝트 만들기') return; goToWizardStep(1);
}}
const handleWheel = (e: WheelEvent) => { imageList={imageList}
// 스크롤 가능한 자식 요소 내부에서 발생한 이벤트인지 확인 onRemoveImage={handleRemoveImage}
const target = e.target as HTMLElement; onAddImages={handleAddImages}
/>
// custom-scrollbar 클래스를 가진 요소 또는 그 자식에서 발생한 이벤트인지 확인 );
const scrollableParent = target.closest('.custom-scrollbar') as HTMLElement; case 1:
if (scrollableParent) { return (
const { scrollTop, scrollHeight, clientHeight } = scrollableParent; <SoundStudioContent
const hasScrollableContent = scrollHeight > clientHeight; onBack={() => goToWizardStep(0)}
const atTop = scrollTop <= 0; onNext={(taskId: string) => {
const atBottom = scrollTop + clientHeight >= scrollHeight - 1; setSongTaskId(taskId);
localStorage.setItem(SONG_TASK_ID_KEY, taskId);
// 스크롤 가능한 콘텐츠가 있는 경우 goToWizardStep(2);
if (hasScrollableContent) { }}
// 위로 스크롤하고 맨 위가 아니거나, 아래로 스크롤하고 맨 아래가 아니면 허용 businessInfo={businessInfo}
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) { imageTaskId={imageTaskId}
return; // 이미지 리스트 스크롤 허용 (preventDefault 하지 않음) videoGenerationStatus={videoGenerationStatus}
} videoGenerationProgress={videoGenerationProgress}
} />
} );
case 2:
const h = container.clientHeight; return (
const currentIdx = Math.round(container.scrollTop / h); <CompletionContent
onBack={() => goToWizardStep(1)}
if (e.deltaY > 0) { // Down songTaskId={songTaskId}
if (currentIdx >= maxWizardIndex) { onVideoStatusChange={setVideoGenerationStatus}
e.preventDefault(); onVideoProgressChange={setVideoGenerationProgress}
} />
);
default:
return null;
} }
}; };
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}, [maxWizardIndex, activeItem]);
const renderContent = () => { const renderContent = () => {
switch (activeItem) { switch (activeItem) {
case '대시보드': case '대시보드':
@ -170,54 +159,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
return <BusinessSettingsContent />; return <BusinessSettingsContent />;
case '새 프로젝트 만들기': case '새 프로젝트 만들기':
return ( return (
<div <div className="wizard-page-container">
ref={scrollContainerRef} {renderWizardContent()}
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> </div>
); );
default: default:

View File

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

View File

@ -7,10 +7,11 @@ interface DisplaySectionProps {
} }
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => { const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
const frames = [ // YouTube Shorts 영상 ID들
{ id: 1, image: '/assets/images/display-frame-1.png' }, const videos = [
{ id: 2, image: '/assets/images/display-frame-1.png' }, { id: 1, videoId: 'OZJ8X4P82OA' },
{ id: 3, image: '/assets/images/display-frame-1.png' }, { id: 2, videoId: 'hNzMO21O40c' },
{ id: 3, videoId: 'dM8_d6Aud68' },
]; ];
return ( return (
@ -18,12 +19,19 @@ const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
<div className="content-safe-area"> <div className="content-safe-area">
{/* Main visual frames container */} {/* Main visual frames container */}
<div className="display-frames"> <div className="display-frames">
{frames.map((frame, index) => ( {videos.map((video, index) => (
<div <div
key={frame.id} key={video.id}
className={`display-frame ${index === 2 ? 'display-frame-hidden-mobile' : ''}`} 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>
))} ))}
</div> </div>

View File

@ -1,10 +1,11 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useRef } from 'react';
interface HeroSectionProps { interface HeroSectionProps {
onAnalyze?: (url: string) => void; onAnalyze?: (url: string) => void;
onNext?: () => void; onNext?: () => void;
error?: string | null; error?: string | null;
scrollProgress?: number; // 0 ~ 1 (스크롤 진행률)
} }
const isValidUrl = (string: string): boolean => { 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 [url, setUrl] = useState('');
const [localError, setLocalError] = 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; const error = externalError || localError;
@ -41,15 +150,26 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
return ( return (
<div className="hero-section"> <div className="hero-section">
{/* Animated background orbs */} {/* Animated background orbs - 스크롤에 따라 빠르게 사라짐 */}
<div className="hero-bg-orbs"> <div
<div className="hero-orb hero-orb-purple-1"></div> className="hero-bg-orbs"
<div className="hero-orb hero-orb-mint-1"></div> style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
<div className="hero-orb hero-orb-purple-2"></div> >
<div className="hero-orb hero-orb-mint-2"></div> {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> </div>
{/* Background blur overlay */}
<div className="hero-bg-blur"></div>
<div className="hero-content"> <div className="hero-content">
{/* Logo Image */} {/* Logo Image */}