브랜드 분석 페이지 css 수정, song id polling 적용 .

main
hbyang 2026-01-22 11:52:19 +09:00
parent e3ed840d12
commit d5b6ad6958
6 changed files with 120 additions and 161 deletions

181
index.css
View File

@ -1821,32 +1821,51 @@
Analysis Result Page Components 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 { .analysis-container {
width: 100%; width: 100%;
min-height: 100%;
color: var(--color-text-white); color: var(--color-text-white);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background-color: #002224; background-color: #002224;
box-sizing: border-box; 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) */ /* Header Area (Back Button + Title) */
.analysis-header-area { .analysis-header-area {
width: 100%; width: 100%;
max-width: 1440px; max-width: 1440px;
padding: 8px 16px; padding: 8px 0;
box-sizing: border-box; box-sizing: border-box;
} }
@media (min-width: 768px) {
.analysis-header-area {
padding: 8px 32px;
}
}
/* Analysis Header */ /* Analysis Header */
.analysis-header { .analysis-header {
width: 100%; width: 100%;
@ -1854,13 +1873,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: 16px 0;
}
@media (min-width: 768px) {
.analysis-header {
padding: 24px 0; padding: 24px 0;
}
} }
.analysis-icon { .analysis-icon {
@ -1869,52 +1882,27 @@
} }
.analysis-icon svg { .analysis-icon svg {
width: 32px;
height: 32px;
}
@media (min-width: 768px) {
.analysis-icon svg {
width: 40px; width: 40px;
height: 40px; height: 40px;
}
} }
/* Analysis Grid */ /* Analysis Grid - 모바일: 세로 배치, 데스크톱: 가로 배치 */
.analysis-grid { .analysis-grid {
width: 100%; width: 100%;
max-width: 1440px; max-width: 1440px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 24px;
padding: 0 16px;
box-sizing: border-box; box-sizing: border-box;
} }
@media (min-width: 768px) { /* 데스크톱(1024px 이상)에서 가로 배치 */
.analysis-grid {
gap: 24px;
padding: 0 32px;
}
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.analysis-grid { .analysis-grid {
flex-direction: row; flex-direction: row;
align-items: flex-start;
gap: 40px; gap: 40px;
padding: 0 60px; padding: 0 200px;
}
}
@media (min-width: 1280px) {
.analysis-grid {
padding: 0 120px;
}
}
@media (min-width: 1440px) {
.analysis-grid {
padding: 0 180px;
} }
} }
@ -1922,10 +1910,10 @@
.brand-identity-card { .brand-identity-card {
background-color: #01393B; background-color: #01393B;
border-radius: 24px; border-radius: 24px;
padding: 20px; padding: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 24px;
border: none; border: none;
box-shadow: none; box-shadow: none;
width: 100%; width: 100%;
@ -1950,19 +1938,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
.brand-content { .brand-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px;
}
@media (min-width: 768px) {
.brand-content {
gap: 16px; gap: 16px;
}
} }
.brand-name { .brand-name {
@ -1982,37 +1963,24 @@
.brand-location { .brand-location {
color: #6AB0B3; color: #6AB0B3;
font-size: 13px; font-size: 14px;
letter-spacing: -0.006em; letter-spacing: -0.006em;
margin: 0; margin: 0;
line-height: 1.2; line-height: 1.2;
} }
@media (min-width: 768px) {
.brand-location {
font-size: 14px;
}
}
.brand-subtitle { .brand-subtitle {
color: #6AB0B3; color: #6AB0B3;
font-size: 13px; font-size: 14px;
font-weight: 400; font-weight: 400;
letter-spacing: -0.006em; letter-spacing: -0.006em;
} }
@media (min-width: 768px) {
.brand-subtitle {
font-size: 14px;
}
}
.brand-info { .brand-info {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
flex-wrap: wrap;
} }
/* Report Content */ /* Report Content */
@ -2036,19 +2004,17 @@
.analysis-cards-column { .analysis-cards-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 24px;
width: 100%; width: 100%;
} }
@media (min-width: 768px) { /* 데스크톱(1024px 이상)에서만 sticky 적용 */
.analysis-cards-column {
gap: 24px;
}
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.analysis-cards-column { .analysis-cards-column {
flex: 1; flex: 1;
position: sticky;
top: 40px;
align-self: flex-start;
} }
} }
@ -2056,12 +2022,12 @@
.analysis-cards-column .feature-card { .analysis-cards-column .feature-card {
background-color: #01393B; background-color: #01393B;
border-radius: 24px; border-radius: 24px;
padding: 20px; padding: 24px;
border: none; border: none;
box-shadow: none; box-shadow: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 24px;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@ -2079,14 +2045,14 @@
flex: 1; flex: 1;
} }
/* Selling Points Grid - 2열 그리드 */ /* Selling Points Grid - 모바일: 1열, 태블릿 이상: 2열 */
.selling-points-grid { .selling-points-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;
} }
@media (min-width: 480px) { @media (min-width: 640px) {
.selling-points-grid { .selling-points-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 0; gap: 0;
@ -2096,67 +2062,50 @@
/* Selling Point Item */ /* Selling Point Item */
.selling-point-item { .selling-point-item {
background-color: #034A4D; background-color: #034A4D;
padding: 16px; padding: 20px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
border-radius: 12px; border-radius: 16px;
} }
@media (min-width: 480px) { @media (min-width: 640px) {
.selling-point-item { .selling-point-item {
padding: 24px 20px; padding: 24px 20px;
gap: 16px; gap: 16px;
border-radius: 0; border-radius: 0;
} }
/* 첫 번째 행 왼쪽 */
/* 첫 번째 행 */
.selling-point-item:nth-child(1) { .selling-point-item:nth-child(1) {
border-radius: 16px 0 0 0; border-radius: 16px 0 0 0;
} }
/* 첫 번째 행 오른쪽 */
.selling-point-item:nth-child(2) { .selling-point-item:nth-child(2) {
border-radius: 0 16px 0 0; border-radius: 0 16px 0 0;
} }
/* 마지막 행 왼쪽 */
/* 마지막 행 */
.selling-point-item:nth-last-child(2):nth-child(odd) { .selling-point-item:nth-last-child(2):nth-child(odd) {
border-radius: 0 0 0 16px; border-radius: 0 0 0 16px;
} }
/* 마지막 행 오른쪽 */
.selling-point-item:nth-last-child(1):nth-child(even) { .selling-point-item:nth-last-child(1):nth-child(even) {
border-radius: 0 0 16px 0; border-radius: 0 0 16px 0;
} }
/* 홀수 개일 때 마지막 아이템 */
.selling-point-item:last-child:nth-child(odd) {
border-radius: 0 0 0 16px;
}
} }
.selling-point-title { .selling-point-title {
font-family: 'Pretendard', sans-serif; font-family: 'Pretendard', sans-serif;
font-size: 13px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #6AB0B3; color: #6AB0B3;
letter-spacing: -0.006em; letter-spacing: -0.006em;
line-height: 1; line-height: 1;
} }
@media (min-width: 768px) {
.selling-point-title {
font-size: 14px;
}
}
.selling-point-content { .selling-point-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px;
}
@media (min-width: 768px) {
.selling-point-content {
gap: 8px; gap: 8px;
}
} }
.selling-point-content p { .selling-point-content p {
@ -2185,23 +2134,17 @@
.tags-wrapper { .tags-wrapper {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 8px;
align-content: flex-start; align-content: flex-start;
} }
@media (min-width: 768px) { /* Feature Tag - 피그마: pill 형태 */
.tags-wrapper {
gap: 8px;
}
}
/* Feature Tag - pill 형태 */
.feature-tag { .feature-tag {
padding: 6px 12px; padding: 8px 16px;
background-color: #034A4D; background-color: #034A4D;
border-radius: 999px; border-radius: 999px;
font-family: 'Pretendard', sans-serif; font-family: 'Pretendard', sans-serif;
font-size: 14px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: #CEE5E6; color: #CEE5E6;
letter-spacing: -0.006em; letter-spacing: -0.006em;
@ -2210,7 +2153,6 @@
@media (min-width: 768px) { @media (min-width: 768px) {
.feature-tag { .feature-tag {
padding: 8px 16px;
font-size: 17px; font-size: 17px;
} }
} }
@ -2221,18 +2163,17 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
padding: 16px; padding: 20px 16px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center;
box-sizing: border-box; box-sizing: border-box;
background: linear-gradient(to top, #002224 60%, transparent); background: linear-gradient(to top, #002224 70%, transparent);
z-index: 100; z-index: 100;
} }
/* Sidebar가 있을 때 버튼 위치 조정 (md breakpoint) */
@media (min-width: 768px) { @media (min-width: 768px) {
.analysis-bottom { .analysis-bottom {
left: 280px;
padding: 24px 32px; padding: 24px 32px;
} }
} }

View File

@ -18,7 +18,6 @@
padding: 0; padding: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden;
background-color: #121a1d; background-color: #121a1d;
} }
body { body {

View File

@ -314,16 +314,16 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0) // 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0)
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || isBrandAnalysis; const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || isBrandAnalysis;
// 브랜드 분석일 때는 높이 제한 없이 자연 스크롤 // 브랜드 분석일 때는 전체 화면 스크롤
if (isBrandAnalysis) { if (isBrandAnalysis) {
return ( return (
<div className="flex w-full bg-[#0d1416] text-white"> <div className="analysis-page-wrapper">
{showSidebar && ( {showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={setActiveItem} onHome={handleHome} /> <Sidebar activeItem={activeItem} onNavigate={setActiveItem} onHome={handleHome} />
)} )}
<div className="flex-1 relative"> <main className="analysis-page-main">
{renderContent()} {renderContent()}
</div> </main>
</div> </div>
); );
} }

View File

@ -22,6 +22,7 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli
interface SavedGenerationState { interface SavedGenerationState {
taskId: string; taskId: string;
songId: string;
lyrics: string; lyrics: string;
status: GenerationStatus; status: GenerationStatus;
timestamp: number; timestamp: number;
@ -70,9 +71,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const languageDropdownRef = useRef<HTMLDivElement>(null); const languageDropdownRef = useRef<HTMLDivElement>(null);
const saveToStorage = (taskId: string, currentLyrics: string, currentStatus: GenerationStatus) => { const saveToStorage = (taskId: string, songId: string, currentLyrics: string, currentStatus: GenerationStatus) => {
const data: SavedGenerationState = { const data: SavedGenerationState = {
taskId, taskId,
songId,
lyrics: currentLyrics, lyrics: currentLyrics,
status: currentStatus, status: currentStatus,
timestamp: Date.now(), timestamp: Date.now(),
@ -112,7 +114,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} }
setStatus('polling'); setStatus('polling');
setStatusMessage('노래를 생성하고 있습니다... (새로고침 후 복구됨)'); setStatusMessage('노래를 생성하고 있습니다... (새로고침 후 복구됨)');
resumePolling(savedState.taskId, savedState.lyrics, 0); resumePolling(savedState.taskId, savedState.songId, savedState.lyrics, 0);
} }
}, []); }, []);
@ -144,25 +146,25 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} }
}, [videoGenerationStatus, songTaskId, onNext]); }, [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 { try {
const downloadResponse = await waitForSongComplete( const statusResponse = await waitForSongComplete(
taskId, songId,
(pollStatus: string) => { (pollStatus: string) => {
if (pollStatus === 'processing') { if (pollStatus === 'streaming') {
setStatusMessage('노래를 생성하고 있습니다...'); setStatusMessage('노래를 생성하고 있습니다...');
} else if (pollStatus === 'uploading') { } else if (pollStatus === 'queued') {
setStatusMessage('노래를 업로드하고 있습니다...'); setStatusMessage('노래 생성 대기 중...');
} }
} }
); );
if (!downloadResponse.success) { if (!statusResponse.success) {
throw new Error(downloadResponse.error_message || '음악 다운로드에 실패했습니다.'); throw new Error(statusResponse.error_message || '음악 생성에 실패했습니다.');
} }
setAudioUrl(downloadResponse.song_result_url); setAudioUrl(statusResponse.song_url);
setSongTaskId(downloadResponse.task_id); setSongTaskId(taskId);
setStatus('complete'); setStatus('complete');
setStatusMessage(''); setStatusMessage('');
setRetryCount(0); setRetryCount(0);
@ -218,8 +220,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.'); throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
} }
saveToStorage(songResponse.task_id, currentLyrics, 'polling'); saveToStorage(songResponse.task_id, songResponse.song_id, currentLyrics, 'polling');
await resumePolling(songResponse.task_id, currentLyrics, currentRetryCount); await resumePolling(songResponse.task_id, songResponse.song_id, currentLyrics, currentRetryCount);
} catch (error) { } catch (error) {
console.error('Song regeneration failed:', error); console.error('Song regeneration failed:', error);

View File

@ -83,6 +83,15 @@ export interface SongGenerateResponse {
error_message: string | null; 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) // 노래 다운로드 상태 조회 응답 (DB Polling)
export interface SongDownloadResponse { export interface SongDownloadResponse {
success: boolean; success: boolean;

View File

@ -6,6 +6,7 @@ import {
LyricDetailResponse, LyricDetailResponse,
SongGenerateRequest, SongGenerateRequest,
SongGenerateResponse, SongGenerateResponse,
SongStatusResponse,
SongDownloadResponse, SongDownloadResponse,
VideoGenerateResponse, VideoGenerateResponse,
VideoStatusResponse, VideoStatusResponse,
@ -153,9 +154,21 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
return response.json(); return response.json();
} }
// 노래 다운로드 상태 조회 API (DB Polling) // 노래 상태 조회 API (Suno Polling)
// task_id를 기반으로 Song 테이블의 상태를 조회하고, completed인 경우 Project 정보와 노래 URL을 반환 export async function getSongStatus(songId: string): Promise<SongStatusResponse> {
export async function getSongDownloadStatus(taskId: string): Promise<SongDownloadResponse> { 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<SongDownloadResponse> {
const response = await fetch(`${API_URL}/song/download/${taskId}`, { const response = await fetch(`${API_URL}/song/download/${taskId}`, {
method: 'GET', method: 'GET',
}); });
@ -168,42 +181,37 @@ export async function getSongDownloadStatus(taskId: string): Promise<SongDownloa
} }
// 노래 생성 완료까지 폴링 (5분 타임아웃, 3초 간격) // 노래 생성 완료까지 폴링 (5분 타임아웃, 3초 간격)
// 새로운 API는 DB 상태를 직접 조회: processing, uploading, completed, failed, not_found, error // Suno API 상태: PENDING, processing, SUCCESS, TEXT_SUCCESS, failed, error
const SONG_POLL_TIMEOUT = 5 * 60 * 1000; // 5분 const SONG_POLL_TIMEOUT = 5 * 60 * 1000; // 5분
const SONG_POLL_INTERVAL = 3000; // 3초 const SONG_POLL_INTERVAL = 3000; // 3초
export async function waitForSongComplete( export async function waitForSongComplete(
taskId: string, songId: string,
onStatusChange?: (status: string) => void onStatusChange?: (status: string) => void
): Promise<SongDownloadResponse> { ): Promise<SongStatusResponse> {
const startTime = Date.now(); const startTime = Date.now();
const poll = async (): Promise<SongDownloadResponse> => { const poll = async (): Promise<SongStatusResponse> => {
// 5분 타임아웃 체크 // 5분 타임아웃 체크
if (Date.now() - startTime > SONG_POLL_TIMEOUT) { if (Date.now() - startTime > SONG_POLL_TIMEOUT) {
throw new Error('TIMEOUT'); throw new Error('TIMEOUT');
} }
try { try {
const response = await getSongDownloadStatus(taskId); const response = await getSongStatus(songId);
onStatusChange?.(response.status); onStatusChange?.(response.status);
// completed: 모든 작업 완료, Blob URL 사용 가능 // SUCCESS 또는 TEXT_SUCCESS: Suno API 노래 생성 완료
if (response.status === 'completed' && response.success) { if ((response.status === 'SUCCESS' || response.status === 'TEXT_SUCCESS') && response.success) {
return response; return response;
} }
// failed: 노래 생성 또는 업로드 실패 // failed 또는 error: Suno API 노래 생성 실패
if (response.status === 'failed' || response.status === 'error') { if (response.status === 'failed' || response.status === 'error') {
throw new Error(response.error_message || '노래 생성에 실패했습니다.'); throw new Error(response.error_message || '노래 생성에 실패했습니다.');
} }
// not_found: task_id에 해당하는 Song 없음 // PENDING, processing 등은 대기 후 재시도
if (response.status === 'not_found') {
throw new Error('노래를 찾을 수 없습니다.');
}
// processing, uploading 등은 대기 후 재시도
await new Promise(resolve => setTimeout(resolve, SONG_POLL_INTERVAL)); await new Promise(resolve => setTimeout(resolve, SONG_POLL_INTERVAL));
return poll(); return poll();
} catch (error) { } catch (error) {