diff --git a/src/components/CompletionView.tsx b/src/components/CompletionView.tsx new file mode 100644 index 0000000..85f4ccd --- /dev/null +++ b/src/components/CompletionView.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +/** + * 생성 완료 화면의 공용 레이아웃 (ADO2 영상 · 썰박스 공통). + * + * **순수 표현 컴포넌트다** — 생성·폴링·다운로드 로직은 전혀 갖지 않는다. + * `CompletionContent`(ADO2)는 영상 생성과 자막 폴링까지 직접 수행하는 컨테이너라 + * 그대로 재사용할 수 없었고, 그래서 화면만 여기로 분리했다. + * + * 양쪽의 차이는 정보 패널뿐이다: + * - ADO2 : 장르 · 해상도 → 가사 전문 + * - 썰박스 : 시나리오 · 생성일시 → 시나리오 설명 + * 그 차이를 `metaLines`/`detail` 슬롯으로 받는다. + * + * 마크업과 클래스명(`comp2-*`)은 기존 ADO2 완료 화면 그대로다. 클래스를 새로 + * 만들지 않아야 [generation-flow.css](../styles/generation-flow.css) 의 반응형· + * 다크 팔레트가 두 화면에 동일하게 적용된다. + */ + +/** 영상 영역이 무엇을 그릴지 */ +export type CompletionVideoState = + /** 생성/렌더 진행 중 — 스피너 + 진행률 */ + | 'loading' + /** 실패 — 메시지 + (있으면) 재시도 버튼 */ + | 'error' + /** 재생 가능 */ + | 'ready' + /** URL 을 아직 못 받았지만 진행 중도 실패도 아님 — 빈 자리만 유지 */ + | 'empty'; + +interface CompletionViewProps { + videoState: CompletionVideoState; + videoUrl?: string | null; + + /** `videoState === 'loading'` 일 때만 쓰인다 */ + loadingText?: string; + /** 0~100. 표시용 값이며 크리핑 보간은 호출부 책임이다 */ + progress?: number; + + /** `videoState === 'error'` 일 때만 쓰인다 */ + errorMessage?: string | null; + /** 없으면 재시도 버튼을 그리지 않는다 (썰박스는 이 화면에서 재시도가 없다) */ + onRetry?: () => void; + + /** 정보 패널 상단 파일명 */ + fileName: string; + /** 메타 줄. 줄 사이에 구분선이 들어간다 */ + metaLines: React.ReactNode[]; + /** 메타 아래 상세 영역의 제목 */ + detailLabel: string; + /** 상세 본문 (ADO2=가사 단락, 썰박스=시나리오 설명) */ + detail: React.ReactNode; + + onDownload: () => void; + downloadDisabled?: boolean; + /** 진행 중에는 '다운로드 중...' 처럼 바뀌므로 호출부가 문구를 정한다 */ + downloadLabel: string; + + onUpload: () => void; + uploadDisabled?: boolean; + + /** + * 이 화면에 딸린 오버레이(SNS 업로드 모달 등). + * + * `.comp2-page` 안에 그려지므로 오버레이 쪽에서 타이포그래피 상속을 + * 끊어야 한다 — `.social-posting-overlay` 의 `text-align` 참조. + */ + children?: React.ReactNode; +} + +const CompletionView: React.FC = ({ + videoState, + videoUrl, + loadingText, + progress = 0, + errorMessage, + onRetry, + fileName, + metaLines, + detailLabel, + detail, + onDownload, + downloadDisabled, + downloadLabel, + onUpload, + uploadDisabled, + children, +}) => { + const { t } = useTranslation(); + + return ( +
+
+

{t('completion.contentComplete')}

+

{t('completion.contentCompleteDesc')}

+
+ +
+
+ {/* 왼쪽: 영상 */} +
+
+ {videoState === 'loading' ? ( +
+
+
+
+
+
+
+

{loadingText}

+
+
+
+
+ {Math.floor(progress)}% +
+
+ ) : videoState === 'error' ? ( +
+ + + + +

{errorMessage}

+ {onRetry && ( + + )} +
+ ) : videoState === 'ready' && videoUrl ? ( +
+
+ + {/* 오른쪽: 콘텐츠 정보 */} +
+
+ {t('completion.contentInfo')} +
+
+

{fileName}

+
+
+
+ {metaLines.map((line, i) => ( + +
+ {line} +
+
+
+ ))} +
+ {detailLabel} + {detail} +
+
+
+ + {/* 하단 버튼 */} +
+ + +
+
+
+
+ + {children} +
+ ); +}; + +export default CompletionView; diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index fc84241..ef2ccfc 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -3,9 +3,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete, trackFirstVideoCreated } from '../../utils/api'; import SocialPostingModal from '../../components/SocialPostingModal'; -import { useTutorial } from '../../components/Tutorial/useTutorial'; -import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; -import TutorialOverlay from '../../components/Tutorial/TutorialOverlay'; +import CompletionView from '../../components/CompletionView'; interface CompletionContentProps { onBack: () => void; @@ -46,22 +44,6 @@ const CompletionContent: React.FC = ({ const hasStartedGeneration = useRef(false); const displayIntervalRef = useRef | null>(null); - const tutorial = useTutorial(); - const tutorialIsActiveRef = useRef(tutorial.isActive); - tutorialIsActiveRef.current = tutorial.isActive; - - // 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동) - useEffect(() => { - const isComplete = videoStatus === 'complete'; - const isProcessing = videoStatus === 'generating' || videoStatus === 'polling'; - - if (isProcessing && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) { - tutorial.startTutorial(TUTORIAL_KEYS.GENERATING); - } else if (isComplete && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) { - tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION); - } - }, [videoStatus]); - // 소셜 미디어 포스팅 모달 const [showSocialModal, setShowSocialModal] = useState(false); const [videoDbId, setVideoDbId] = useState(null); @@ -410,175 +392,97 @@ const CompletionContent: React.FC = ({ return `${businessName}.mp4`; }; + /** + * 가사 단락 파싱. + * + * `[Verse]` 처럼 대괄호만 있는 줄을 섹션 머리로 보고, 뒤따르는 줄들을 그 섹션에 + * 묶는다. 태그 없이 시작하는 가사도 있어 첫 섹션은 태그 없이 열릴 수 있다. + */ + const renderLyrics = () => { + if (!songCompletionData) { + return

{t('completion.sampleLyrics')}

; + } + if (!songCompletionData.lyrics) { + return

{t('completion.noLyricsBGM')}

; + } + const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim()); + const sections: { tag: string | null; lines: string[] }[] = []; + lines.forEach((line: string) => { + const tagMatch = line.trim().match(/^\[(.+)\]$/); + if (tagMatch) { + sections.push({ tag: `[${tagMatch[1]}]`, lines: [] }); + } else if (sections.length === 0) { + sections.push({ tag: null, lines: [line] }); + } else { + sections[sections.length - 1].lines.push(line); + } + }); + return ( +
+ {sections + .filter((s) => s.lines.length > 0) + .map((section, i) => ( +
+ {section.tag && {section.tag}} +

{section.lines.join('\n')}

+
+ ))} +
+ ); + }; + return ( -
- {/*
- -
*/} - -
-

{t('completion.contentComplete')}

-

{t('completion.contentCompleteDesc')}

-
- -
-
- {/* 왼쪽: 영상 */} -
-
- {isLoading ? ( -
-
-
-
-
-
-
-

{statusMessage}

-
-
-
-
- {Math.floor(displayProgress)}% -
-
- ) : videoStatus === 'error' ? ( -
- - - - -

{errorMessage}

- -
- ) : videoUrl ? ( -
-
- - {/* 오른쪽: 콘텐츠 정보 */} -
-
- {t('completion.contentInfo')} -
-
-

{getFileName()}

- {/*

19.6MB

*/} -
-
-
-
- {t('completion.genre')} : {songCompletionData?.genre || 'K-POP'} -
-
-
- {t('completion.resolution')} : {getVideoResolution()} -
-
-
- {t('completion.lyrics')} - {(() => { - if (!songCompletionData) { - return

{t('completion.sampleLyrics')}

; - } - if (!songCompletionData.lyrics) { - return

{t('completion.noLyricsBGM')}

; - } - const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim()); - const sections: { tag: string | null; lines: string[] }[] = []; - lines.forEach((line: string) => { - const tagMatch = line.trim().match(/^\[(.+)\]$/); - if (tagMatch) { - sections.push({ tag: `[${tagMatch[1]}]`, lines: [] }); - } else if (sections.length === 0) { - sections.push({ tag: null, lines: [line] }); - } else { - sections[sections.length - 1].lines.push(line); - } - }); - return ( -
- {sections.filter(s => s.lines.length > 0).map((section, i) => ( -
- {section.tag && {section.tag}} -

{section.lines.join('\n')}

-
- ))} -
- ); - })()} -
-
-
- {/* 하단 버튼 */} -
- - -
-
-
-
- - {/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */} - - - {tutorial.isActive && ( - - )} -
+ + {/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */} + + ); }; diff --git a/src/pages/Ssulbox/SsulResultContent.tsx b/src/pages/Ssulbox/SsulResultContent.tsx index d31cc55..10f8ba9 100644 --- a/src/pages/Ssulbox/SsulResultContent.tsx +++ b/src/pages/Ssulbox/SsulResultContent.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SsulTaskStatus, getSsulTask } from '../../utils/api'; -import { SCEN, Scen, scenName } from './ssulData'; -import { IcDownload } from './ssulIcons'; +import { VideoListItem } from '../../types/api'; +import SocialPostingModal from '../../components/SocialPostingModal'; +import CompletionView from '../../components/CompletionView'; +import { Scen, scenDesc, scenName } from './ssulData'; interface SsulResultContentProps { taskId: number; @@ -12,9 +14,18 @@ interface SsulResultContentProps { /** * 썰박스 생성 완료 화면. * - * 완성된 영상을 재생하고 다운로드할 수 있다. - * SNS 업로드는 castad `SocialPostingModal` 재사용을 먼저 시도해야 하므로 - * 통합 목록 작업(Phase 8)과 함께 붙인다. + * **화면은 ADO2 완료 화면과 같은 `CompletionView` 를 쓴다**(2026-08-10 통일). + * 이전에는 `.ssul-scope` 안의 중앙 1단 레이아웃이었는데, 같은 제품 안에서 + * 완료 화면이 둘로 갈리는 것이 혼란스러웠고 영상 로딩·에러 처리도 없었다. + * 정보 패널만 썰박스 재료(시나리오·생성일시·시나리오 설명)로 채운다. + * + * `.ssul-scope` 를 벗어나면서 부수 효과가 하나 있다: 예전에는 상위의 + * `text-align: center` 가 SNS 업로드 모달 라벨까지 가운데로 끌어당겼는데 + * 그 경로가 사라졌다. 다만 오버레이 쪽 방어(`.social-posting-overlay` 의 + * `text-align`)는 다른 마운트 지점을 위해 그대로 둔다. + * + * 업로드는 castad `SocialPostingModal` 을 그대로 쓴다 — social_upload 병합으로 + * 모달이 `content_type` 을 함께 보내므로 종류 분기가 모달 내부에서 끝난다. */ const SsulResultContent: React.FC = ({ taskId, scenario }) => { const { t } = useTranslation(); @@ -22,6 +33,7 @@ const SsulResultContent: React.FC = ({ taskId, scenario const [loadError, setLoadError] = useState(false); // 영상 전체를 받아오는 동안 시간이 걸린다 — 중복 클릭을 막고 진행을 알린다 const [downloading, setDownloading] = useState(false); + const [showSocialModal, setShowSocialModal] = useState(false); // 폴링이 끝난 뒤 최종 상태(video_url 포함)를 한 번 더 읽는다. // 진행 중에는 video_url 이 비어 있으므로 완료 후 조회가 필요하다. @@ -35,7 +47,8 @@ const SsulResultContent: React.FC = ({ taskId, scenario }; }, [taskId]); - const palette = SCEN[scenario]; + // 파일명은 프론트가 정한다(백엔드는 title 을 갖지 않는다) + const fileName = `ssulbox-${scenario}-${taskId}.mp4`; /** * 영상 다운로드. @@ -51,8 +64,6 @@ const SsulResultContent: React.FC = ({ taskId, scenario */ const handleDownload = async () => { if (!task?.video_url || downloading) return; - // 파일명은 프론트가 정한다(백엔드는 title 을 갖지 않는다) - const fileName = `ssulbox-${scenario}-${taskId}.mp4`; setDownloading(true); try { const response = await fetch(task.video_url); @@ -79,54 +90,55 @@ const SsulResultContent: React.FC = ({ taskId, scenario }; return ( -
- - {t(scenName(scenario))} - - -

{t('ssulbox.result.title')}

- - {loadError ? ( -
{t('ssulbox.result.loadFailed')}
- ) : task?.video_url ? ( -