모든 ui / ux 변경 작업 .
parent
95928c0b66
commit
f8bebdac52
374
index.css
374
index.css
|
|
@ -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;
|
||||
|
|
|
|||
19
src/App.tsx
19
src/App.tsx
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<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">
|
||||
<div className="report-section">
|
||||
<button
|
||||
onClick={() => setShowFullReport(!showFullReport)}
|
||||
className="report-toggle"
|
||||
>
|
||||
{showFullReport ? '간략히 보기' : '자세히 보기'}
|
||||
</button>
|
||||
</div>
|
||||
<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 */}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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,61 +101,56 @@ 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);
|
||||
|
||||
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();
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}, [maxWizardIndex, activeItem]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeItem) {
|
||||
case '대시보드':
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
{['보컬', '배경음악', '성우 내레이션'].map(type => {
|
||||
const isDisabled = type === '성우 내레이션' || isGenerating;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
disabled={isGenerating}
|
||||
className={`sound-type-btn ${selectedType === type ? 'active' : ''}`}
|
||||
onClick={() => !isDisabled && setSelectedType(type)}
|
||||
disabled={isDisabled}
|
||||
className={`sound-type-btn ${selectedType === type ? 'active' : ''} ${type === '성우 내레이션' ? 'permanently-disabled' : ''}`}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue