diff --git a/index.css b/index.css index eb40e60..bb77d41 100644 --- a/index.css +++ b/index.css @@ -1821,32 +1821,51 @@ Analysis Result Page Components ===================================================== */ -/* Analysis Container - 높이 제한 없음, 자연 스크롤 */ +/* Analysis Page Wrapper - 전체 레이아웃 */ +.analysis-page-wrapper { + display: flex; + width: 100%; + height: 100vh; + height: 100dvh; + background-color: #002224; + color: white; +} + +/* Analysis Page Main - 스크롤 가능한 메인 영역 */ +.analysis-page-main { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + height: 100%; +} + +/* Analysis Container - 콘텐츠 래퍼 */ .analysis-container { width: 100%; + min-height: 100%; color: var(--color-text-white); display: flex; flex-direction: column; align-items: center; background-color: #002224; box-sizing: border-box; - padding-bottom: 100px; /* 고정 버튼 영역 확보 */ + padding: 0 16px 120px 16px; /* 고정 버튼 영역 확보 */ +} + +@media (min-width: 768px) { + .analysis-container { + padding: 0 32px 120px 32px; + } } /* Header Area (Back Button + Title) */ .analysis-header-area { width: 100%; max-width: 1440px; - padding: 8px 16px; + padding: 8px 0; box-sizing: border-box; } -@media (min-width: 768px) { - .analysis-header-area { - padding: 8px 32px; - } -} - /* Analysis Header */ .analysis-header { width: 100%; @@ -1854,13 +1873,7 @@ flex-direction: column; align-items: center; text-align: center; - padding: 16px 0; -} - -@media (min-width: 768px) { - .analysis-header { - padding: 24px 0; - } + padding: 24px 0; } .analysis-icon { @@ -1869,52 +1882,27 @@ } .analysis-icon svg { - width: 32px; - height: 32px; + width: 40px; + height: 40px; } -@media (min-width: 768px) { - .analysis-icon svg { - width: 40px; - height: 40px; - } -} - -/* Analysis Grid */ +/* Analysis Grid - 모바일: 세로 배치, 데스크톱: 가로 배치 */ .analysis-grid { width: 100%; max-width: 1440px; display: flex; flex-direction: column; - gap: 16px; - padding: 0 16px; + gap: 24px; box-sizing: border-box; } -@media (min-width: 768px) { - .analysis-grid { - gap: 24px; - padding: 0 32px; - } -} - +/* 데스크톱(1024px 이상)에서 가로 배치 */ @media (min-width: 1024px) { .analysis-grid { flex-direction: row; + align-items: flex-start; gap: 40px; - padding: 0 60px; - } -} - -@media (min-width: 1280px) { - .analysis-grid { - padding: 0 120px; - } -} - -@media (min-width: 1440px) { - .analysis-grid { - padding: 0 180px; + padding: 0 200px; } } @@ -1922,10 +1910,10 @@ .brand-identity-card { background-color: #01393B; border-radius: 24px; - padding: 20px; + padding: 24px; display: flex; flex-direction: column; - gap: 20px; + gap: 24px; border: none; box-shadow: none; width: 100%; @@ -1950,19 +1938,12 @@ display: flex; align-items: center; gap: 8px; - flex-wrap: wrap; } .brand-content { display: flex; flex-direction: column; - gap: 12px; -} - -@media (min-width: 768px) { - .brand-content { - gap: 16px; - } + gap: 16px; } .brand-name { @@ -1982,37 +1963,24 @@ .brand-location { color: #6AB0B3; - font-size: 13px; + font-size: 14px; letter-spacing: -0.006em; margin: 0; line-height: 1.2; } -@media (min-width: 768px) { - .brand-location { - font-size: 14px; - } -} - .brand-subtitle { color: #6AB0B3; - font-size: 13px; + font-size: 14px; font-weight: 400; letter-spacing: -0.006em; } -@media (min-width: 768px) { - .brand-subtitle { - font-size: 14px; - } -} - .brand-info { display: flex; flex-direction: row; align-items: center; gap: 4px; - flex-wrap: wrap; } /* Report Content */ @@ -2036,19 +2004,17 @@ .analysis-cards-column { display: flex; flex-direction: column; - gap: 16px; + gap: 24px; width: 100%; } -@media (min-width: 768px) { - .analysis-cards-column { - gap: 24px; - } -} - +/* 데스크톱(1024px 이상)에서만 sticky 적용 */ @media (min-width: 1024px) { .analysis-cards-column { flex: 1; + position: sticky; + top: 40px; + align-self: flex-start; } } @@ -2056,12 +2022,12 @@ .analysis-cards-column .feature-card { background-color: #01393B; border-radius: 24px; - padding: 20px; + padding: 24px; border: none; box-shadow: none; display: flex; flex-direction: column; - gap: 20px; + gap: 24px; width: 100%; box-sizing: border-box; } @@ -2079,14 +2045,14 @@ flex: 1; } -/* Selling Points Grid - 2열 그리드 */ +/* Selling Points Grid - 모바일: 1열, 태블릿 이상: 2열 */ .selling-points-grid { display: grid; grid-template-columns: 1fr; gap: 8px; } -@media (min-width: 480px) { +@media (min-width: 640px) { .selling-points-grid { grid-template-columns: 1fr 1fr; gap: 0; @@ -2096,67 +2062,50 @@ /* Selling Point Item */ .selling-point-item { background-color: #034A4D; - padding: 16px; + padding: 20px 16px; display: flex; flex-direction: column; gap: 12px; - border-radius: 12px; + border-radius: 16px; } -@media (min-width: 480px) { +@media (min-width: 640px) { .selling-point-item { padding: 24px 20px; gap: 16px; border-radius: 0; } - - /* 첫 번째 행 */ + /* 첫 번째 행 왼쪽 */ .selling-point-item:nth-child(1) { border-radius: 16px 0 0 0; } + /* 첫 번째 행 오른쪽 */ .selling-point-item:nth-child(2) { border-radius: 0 16px 0 0; } - - /* 마지막 행 */ + /* 마지막 행 왼쪽 */ .selling-point-item:nth-last-child(2):nth-child(odd) { border-radius: 0 0 0 16px; } + /* 마지막 행 오른쪽 */ .selling-point-item:nth-last-child(1):nth-child(even) { border-radius: 0 0 16px 0; } - - /* 홀수 개일 때 마지막 아이템 */ - .selling-point-item:last-child:nth-child(odd) { - border-radius: 0 0 0 16px; - } } .selling-point-title { font-family: 'Pretendard', sans-serif; - font-size: 13px; + font-size: 14px; font-weight: 600; color: #6AB0B3; letter-spacing: -0.006em; line-height: 1; } -@media (min-width: 768px) { - .selling-point-title { - font-size: 14px; - } -} - .selling-point-content { display: flex; flex-direction: column; - gap: 6px; -} - -@media (min-width: 768px) { - .selling-point-content { - gap: 8px; - } + gap: 8px; } .selling-point-content p { @@ -2185,23 +2134,17 @@ .tags-wrapper { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; align-content: flex-start; } -@media (min-width: 768px) { - .tags-wrapper { - gap: 8px; - } -} - -/* Feature Tag - pill 형태 */ +/* Feature Tag - 피그마: pill 형태 */ .feature-tag { - padding: 6px 12px; + padding: 8px 16px; background-color: #034A4D; border-radius: 999px; font-family: 'Pretendard', sans-serif; - font-size: 14px; + font-size: 15px; font-weight: 500; color: #CEE5E6; letter-spacing: -0.006em; @@ -2210,7 +2153,6 @@ @media (min-width: 768px) { .feature-tag { - padding: 8px 16px; font-size: 17px; } } @@ -2221,18 +2163,17 @@ bottom: 0; left: 0; right: 0; - padding: 16px; + padding: 20px 16px; display: flex; justify-content: center; + align-items: center; box-sizing: border-box; - background: linear-gradient(to top, #002224 60%, transparent); + background: linear-gradient(to top, #002224 70%, transparent); z-index: 100; } -/* Sidebar가 있을 때 버튼 위치 조정 (md breakpoint) */ @media (min-width: 768px) { .analysis-bottom { - left: 280px; padding: 24px 32px; } } diff --git a/index.html b/index.html index e0ae73d..d79ed9d 100755 --- a/index.html +++ b/index.html @@ -18,7 +18,6 @@ padding: 0; width: 100%; height: 100%; - overflow: hidden; background-color: #121a1d; } body { diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index d57320d..ccb142e 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -314,16 +314,16 @@ const GenerationFlow: React.FC = ({ // 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0) const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || isBrandAnalysis; - // 브랜드 분석일 때는 높이 제한 없이 자연 스크롤 + // 브랜드 분석일 때는 전체 화면 스크롤 if (isBrandAnalysis) { return ( -
+
{showSidebar && ( )} -
+
{renderContent()} -
+
); } diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx index 0670a59..e1fbf34 100755 --- a/src/pages/Dashboard/SoundStudioContent.tsx +++ b/src/pages/Dashboard/SoundStudioContent.tsx @@ -22,6 +22,7 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli interface SavedGenerationState { taskId: string; + songId: string; lyrics: string; status: GenerationStatus; timestamp: number; @@ -70,9 +71,10 @@ const SoundStudioContent: React.FC = ({ const audioRef = useRef(null); const languageDropdownRef = useRef(null); - const saveToStorage = (taskId: string, currentLyrics: string, currentStatus: GenerationStatus) => { + const saveToStorage = (taskId: string, songId: string, currentLyrics: string, currentStatus: GenerationStatus) => { const data: SavedGenerationState = { taskId, + songId, lyrics: currentLyrics, status: currentStatus, timestamp: Date.now(), @@ -112,7 +114,7 @@ const SoundStudioContent: React.FC = ({ } setStatus('polling'); setStatusMessage('노래를 생성하고 있습니다... (새로고침 후 복구됨)'); - resumePolling(savedState.taskId, savedState.lyrics, 0); + resumePolling(savedState.taskId, savedState.songId, savedState.lyrics, 0); } }, []); @@ -144,25 +146,25 @@ const SoundStudioContent: React.FC = ({ } }, [videoGenerationStatus, songTaskId, onNext]); - const resumePolling = async (taskId: string, currentLyrics: string, currentRetryCount: number = 0) => { + const resumePolling = async (taskId: string, songId: string, currentLyrics: string, currentRetryCount: number = 0) => { try { - const downloadResponse = await waitForSongComplete( - taskId, + const statusResponse = await waitForSongComplete( + songId, (pollStatus: string) => { - if (pollStatus === 'processing') { + if (pollStatus === 'streaming') { setStatusMessage('노래를 생성하고 있습니다...'); - } else if (pollStatus === 'uploading') { - setStatusMessage('노래를 업로드하고 있습니다...'); + } else if (pollStatus === 'queued') { + setStatusMessage('노래 생성 대기 중...'); } } ); - if (!downloadResponse.success) { - throw new Error(downloadResponse.error_message || '음악 다운로드에 실패했습니다.'); + if (!statusResponse.success) { + throw new Error(statusResponse.error_message || '음악 생성에 실패했습니다.'); } - setAudioUrl(downloadResponse.song_result_url); - setSongTaskId(downloadResponse.task_id); + setAudioUrl(statusResponse.song_url); + setSongTaskId(taskId); setStatus('complete'); setStatusMessage(''); setRetryCount(0); @@ -218,8 +220,8 @@ const SoundStudioContent: React.FC = ({ throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.'); } - saveToStorage(songResponse.task_id, currentLyrics, 'polling'); - await resumePolling(songResponse.task_id, currentLyrics, currentRetryCount); + saveToStorage(songResponse.task_id, songResponse.song_id, currentLyrics, 'polling'); + await resumePolling(songResponse.task_id, songResponse.song_id, currentLyrics, currentRetryCount); } catch (error) { console.error('Song regeneration failed:', error); diff --git a/src/types/api.ts b/src/types/api.ts index 7737e27..f510014 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -83,6 +83,15 @@ export interface SongGenerateResponse { error_message: string | null; } +// 노래 상태 조회 응답 (Suno Polling) +export interface SongStatusResponse { + success: boolean; + status: string; + message: string; + song_url: string | null; + error_message: string | null; +} + // 노래 다운로드 상태 조회 응답 (DB Polling) export interface SongDownloadResponse { success: boolean; diff --git a/src/utils/api.ts b/src/utils/api.ts index e1d17a8..0cce6da 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -6,6 +6,7 @@ import { LyricDetailResponse, SongGenerateRequest, SongGenerateResponse, + SongStatusResponse, SongDownloadResponse, VideoGenerateResponse, VideoStatusResponse, @@ -153,9 +154,21 @@ export async function generateSong(taskId: string, request: SongGenerateRequest) return response.json(); } -// 노래 다운로드 상태 조회 API (DB Polling) -// task_id를 기반으로 Song 테이블의 상태를 조회하고, completed인 경우 Project 정보와 노래 URL을 반환 -export async function getSongDownloadStatus(taskId: string): Promise { +// 노래 상태 조회 API (Suno Polling) +export async function getSongStatus(songId: string): Promise { + const response = await fetch(`${API_URL}/song/status/${songId}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 노래 다운로드 API +export async function downloadSong(taskId: string): Promise { const response = await fetch(`${API_URL}/song/download/${taskId}`, { method: 'GET', }); @@ -168,42 +181,37 @@ export async function getSongDownloadStatus(taskId: string): Promise void -): Promise { +): Promise { const startTime = Date.now(); - const poll = async (): Promise => { + const poll = async (): Promise => { // 5분 타임아웃 체크 if (Date.now() - startTime > SONG_POLL_TIMEOUT) { throw new Error('TIMEOUT'); } try { - const response = await getSongDownloadStatus(taskId); + const response = await getSongStatus(songId); onStatusChange?.(response.status); - // completed: 모든 작업 완료, Blob URL 사용 가능 - if (response.status === 'completed' && response.success) { + // SUCCESS 또는 TEXT_SUCCESS: Suno API 노래 생성 완료 + if ((response.status === 'SUCCESS' || response.status === 'TEXT_SUCCESS') && response.success) { return response; } - // failed: 노래 생성 또는 업로드 실패 + // failed 또는 error: Suno API 노래 생성 실패 if (response.status === 'failed' || response.status === 'error') { throw new Error(response.error_message || '노래 생성에 실패했습니다.'); } - // not_found: task_id에 해당하는 Song 없음 - if (response.status === 'not_found') { - throw new Error('노래를 찾을 수 없습니다.'); - } - - // processing, uploading 등은 대기 후 재시도 + // PENDING, processing 등은 대기 후 재시도 await new Promise(resolve => setTimeout(resolve, SONG_POLL_INTERVAL)); return poll(); } catch (error) {