프로그래스바 추가 및 UI 수정

feature-credit
김성경 2026-04-23 13:20:58 +09:00
parent 6675b5301f
commit 29cee13da7
10 changed files with 232 additions and 136 deletions

111
index.css
View File

@ -5691,6 +5691,37 @@
} }
} }
/* Loading Progress Bar */
.loading-progress-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-top: 24px;
width: 240px;
}
.loading-progress-bar {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
}
.loading-progress-fill {
height: 100%;
background: #a6ffea;
border-radius: 2px;
transition: width 0.1s linear;
}
.loading-progress-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
font-variant-numeric: tabular-nums;
}
/* Loading Spinner Wrapper */ /* Loading Spinner Wrapper */
.loading-spinner-wrapper { .loading-spinner-wrapper {
width: 120px; width: 120px;
@ -7972,7 +8003,7 @@
/* Generate Sound Button */ /* Generate Sound Button */
.btn-generate-sound { .btn-generate-sound {
height: 48px; height: 40px;
min-width: 120px; min-width: 120px;
padding: 0.625rem 1.25rem; padding: 0.625rem 1.25rem;
background-color: #94FBE0; background-color: #94FBE0;
@ -7989,6 +8020,7 @@
gap: 0.5rem; gap: 0.5rem;
line-height: 1.19; line-height: 1.19;
letter-spacing: -0.006em; letter-spacing: -0.006em;
margin-top: 10px;
} }
.btn-generate-sound:hover:not(.disabled) { .btn-generate-sound:hover:not(.disabled) {
@ -8000,6 +8032,12 @@
cursor: not-allowed; cursor: not-allowed;
} }
.regenerate-hint {
font-size: 14px;
color: #CEE5E6;
text-align: center;
}
/* Video Generate Button */ /* Video Generate Button */
.btn-video-generate { .btn-video-generate {
padding: 0.625rem 2.5rem; padding: 0.625rem 2.5rem;
@ -8007,6 +8045,7 @@
border: none; border: none;
border-radius: var(--radius-full); border-radius: var(--radius-full);
color: #FFFFFF; color: #FFFFFF;
height: 40px;
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@ -8086,6 +8125,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 10px;
} }
/* Responsive Styles for Sound Studio */ /* Responsive Styles for Sound Studio */
@ -10621,13 +10661,29 @@
pointer-events: all; pointer-events: all;
} }
@keyframes tutorial-pulse {
0%, 100% {
border-color: rgba(166, 255, 234, 0.9);
box-shadow: 0 0 0 2px rgba(166, 255, 234, 0.3),
0 0 16px 4px rgba(166, 255, 234, 0.35),
0 0 40px 8px rgba(166, 255, 234, 0.15);
}
50% {
border-color: rgba(166, 255, 234, 0.5);
box-shadow: 0 0 0 5px rgba(166, 255, 234, 0.12),
0 0 32px 10px rgba(166, 255, 234, 0.5),
0 0 64px 20px rgba(166, 255, 234, 0.2);
}
}
.tutorial-spotlight-ring { .tutorial-spotlight-ring {
position: fixed; position: fixed;
border: 2px solid rgba(166, 255, 234, 0.85); border: 4px solid rgba(166, 255, 234, 0.85);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 0 0 1px rgba(166, 255, 234, 0.2); box-shadow: 0 0 0 1px rgba(166, 255, 234, 0.2), 0 0 12px 2px rgba(166, 255, 234, 0.15);
pointer-events: none; pointer-events: none;
z-index: 10001; z-index: 10001;
animation: tutorial-pulse 2s ease-in-out infinite;
} }
.tutorial-tooltip { .tutorial-tooltip {
@ -10914,3 +10970,52 @@
border-color: rgba(166, 255, 234, 0.5); border-color: rgba(166, 255, 234, 0.5);
color: #a6ffea; color: #a6ffea;
} }
/* 튜토리얼 토글 버튼 */
.tutorial-toggle-fab {
position: fixed;
top: 16px;
right: 20px;
z-index: 900;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
background: rgba(0, 34, 36, 0.85);
border: 1px solid rgba(166, 255, 234, 0.25);
border-radius: 20px;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 13px;
backdrop-filter: blur(6px);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.tutorial-toggle-fab:hover {
background: rgba(166, 255, 234, 0.12);
border-color: rgba(166, 255, 234, 0.5);
color: #a6ffea;
}
.tutorial-toggle-fab.active {
color: #a6ffea;
border-color: rgba(166, 255, 234, 0.4);
}
.tutorial-toggle-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.15s, color 0.15s;
}
.tutorial-toggle-badge.on {
background: #a6ffea;
color: #002224;
}
.tutorial-toggle-badge.off {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.4);
}

View File

@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>CASTAD</title> <title>ADO2</title>
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32"> <link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48"> <link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>

View File

@ -46,14 +46,6 @@ export const tutorialSteps: TutorialStepDef[] = [
{ {
key: TUTORIAL_KEYS.LANDING, key: TUTORIAL_KEYS.LANDING,
hints: [ hints: [
// {
// targetSelector: '.tutorial-center-anchor',
// titleKey: 'tutorial.landing.intro.title',
// descriptionKey: 'tutorial.landing.intro.desc',
// position: 'bottom',
// clickToAdvance: false,
// noSpotlight: true,
// },
{ {
targetSelector: '.hero-dropdown-trigger', targetSelector: '.hero-dropdown-trigger',
titleKey: 'tutorial.landing.dropdown.title', titleKey: 'tutorial.landing.dropdown.title',
@ -83,46 +75,6 @@ export const tutorialSteps: TutorialStepDef[] = [
}, },
], ],
}, },
// {
// key: TUTORIAL_KEYS.ANALYSIS,
// hints: [
// {
// targetSelector: '.bi2-identity-card',
// titleKey: 'tutorial.analysis.identity.title',
// descriptionKey: 'tutorial.analysis.identity.desc',
// position: 'top',
// clickToAdvance: false,
// },
// {
// targetSelector: '.bi2-selling-card',
// titleKey: 'tutorial.analysis.selling.title',
// descriptionKey: 'tutorial.analysis.selling.desc',
// position: 'top',
// clickToAdvance: false,
// },
// {
// targetSelector: '.bi2-persona-grid',
// titleKey: 'tutorial.analysis.persona.title',
// descriptionKey: 'tutorial.analysis.persona.desc',
// position: 'top',
// clickToAdvance: false,
// },
// {
// targetSelector: '.bi2-keyword-tags',
// titleKey: 'tutorial.analysis.keywords.title',
// descriptionKey: 'tutorial.analysis.keywords.desc',
// position: 'top',
// clickToAdvance: false,
// },
// {
// targetSelector: '.bi2-generate-btn',
// titleKey: 'tutorial.analysis.generate.title',
// descriptionKey: 'tutorial.analysis.generate.desc',
// position: 'right',
// clickToAdvance: true,
// },
// ],
// },
{ {
key: TUTORIAL_KEYS.ASSET, key: TUTORIAL_KEYS.ASSET,
hints: [ hints: [
@ -167,7 +119,7 @@ export const tutorialSteps: TutorialStepDef[] = [
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.language-selector-wrapper', targetSelector: '.language-grid',
titleKey: 'tutorial.sound.language.title', titleKey: 'tutorial.sound.language.title',
descriptionKey: 'tutorial.sound.language.desc', descriptionKey: 'tutorial.sound.language.desc',
position: 'top', position: 'top',
@ -393,7 +345,7 @@ export const tutorialSteps: TutorialStepDef[] = [
titleKey: 'tutorial.contentCalendar.grid.title', titleKey: 'tutorial.contentCalendar.grid.title',
descriptionKey: 'tutorial.contentCalendar.grid.desc', descriptionKey: 'tutorial.contentCalendar.grid.desc',
position: 'top', position: 'top',
clickToAdvance: true, clickToAdvance: false,
}, },
{ {
targetSelector: '.calendar-side-panel', targetSelector: '.calendar-side-panel',

View File

@ -19,6 +19,7 @@ function getGroupProgress(key: string, currentIndex: number): { groupTotal: numb
const SEEN_KEY = 'ado2_tutorial_seen'; const SEEN_KEY = 'ado2_tutorial_seen';
const PROGRESS_KEY = 'ado2_tutorial_progress'; const PROGRESS_KEY = 'ado2_tutorial_progress';
const ENABLED_KEY = 'ado2_tutorial_enabled';
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리 // 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
let globalSkip: (() => void) | null = null; let globalSkip: (() => void) | null = null;
@ -66,6 +67,7 @@ function clearProgress(key: string) {
interface UseTutorialReturn { interface UseTutorialReturn {
isActive: boolean; isActive: boolean;
isEnabled: boolean;
isRestartPopupVisible: boolean; isRestartPopupVisible: boolean;
currentHintIndex: number; currentHintIndex: number;
hints: TutorialHint[]; hints: TutorialHint[];
@ -75,6 +77,7 @@ interface UseTutorialReturn {
nextHint: () => void; nextHint: () => void;
prevHint: () => void; prevHint: () => void;
skipTutorial: () => void; skipTutorial: () => void;
toggleTutorial: (currentKey: string | null) => void;
showRestartPopup: (key: string) => void; showRestartPopup: (key: string) => void;
confirmRestart: () => void; confirmRestart: () => void;
cancelRestart: () => void; cancelRestart: () => void;
@ -83,6 +86,7 @@ interface UseTutorialReturn {
export function useTutorial(): UseTutorialReturn { export function useTutorial(): UseTutorialReturn {
const [isActive, setIsActive] = useState(false); const [isActive, setIsActive] = useState(false);
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== 'false');
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false); const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null); const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
const [currentHintIndex, setCurrentHintIndex] = useState(0); const [currentHintIndex, setCurrentHintIndex] = useState(0);
@ -91,6 +95,7 @@ export function useTutorial(): UseTutorialReturn {
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined); const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => { const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
if (localStorage.getItem(ENABLED_KEY) === 'false') return;
const step = tutorialSteps.find(s => s.key === key); const step = tutorialSteps.find(s => s.key === key);
if (!step || step.hints.length === 0) return; if (!step || step.hints.length === 0) return;
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리 // 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
@ -139,6 +144,24 @@ export function useTutorial(): UseTutorialReturn {
setCurrentHintIndex(0); setCurrentHintIndex(0);
}, [tutorialKey, currentHintIndex]); }, [tutorialKey, currentHintIndex]);
const toggleTutorial = useCallback((currentKey: string | null) => {
if (isEnabled) {
// off: 튜토리얼 중단 + 비활성화
setIsActive(false);
setIsEnabled(false);
localStorage.setItem(ENABLED_KEY, 'false');
} else {
// on: seen/progress 초기화 + 현재 화면 튜토리얼 시작
localStorage.removeItem(SEEN_KEY);
localStorage.removeItem(PROGRESS_KEY);
localStorage.setItem(ENABLED_KEY, 'true');
setIsEnabled(true);
if (currentKey) {
startTutorial(currentKey, undefined, true);
}
}
}, [isEnabled, startTutorial]);
// 튜토리얼 다시 보기: 팝업 표시만 // 튜토리얼 다시 보기: 팝업 표시만
const showRestartPopup = useCallback((key: string) => { const showRestartPopup = useCallback((key: string) => {
setPendingRestartKey(key); setPendingRestartKey(key);
@ -168,6 +191,7 @@ export function useTutorial(): UseTutorialReturn {
return { return {
isActive, isActive,
isEnabled,
isRestartPopupVisible, isRestartPopupVisible,
currentHintIndex, currentHintIndex,
hints, hints,
@ -177,6 +201,7 @@ export function useTutorial(): UseTutorialReturn {
nextHint, nextHint,
prevHint, prevHint,
skipTutorial, skipTutorial,
toggleTutorial,
showRestartPopup, showRestartPopup,
confirmRestart, confirmRestart,
cancelRestart, cancelRestart,

View File

@ -14,7 +14,10 @@
"defaultUser": "User", "defaultUser": "User",
"loggingOut": "Logging out...", "loggingOut": "Logging out...",
"logout": "Log Out", "logout": "Log Out",
"tutorialRestart": "Restart Tutorial" "tutorialRestart": "Restart Tutorial",
"tutorial": "Tutorial",
"tutorialOn": "Enable Tutorial",
"tutorialOff": "Disable Tutorial"
}, },
"tutorial": { "tutorial": {
"skip": "Skip", "skip": "Skip",
@ -27,13 +30,6 @@
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." }, "field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." } "button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
}, },
"analysis": {
"identity": { "title": "Brand Identity", "desc": "Check the core values and market positioning AI analyzed for your brand." },
"selling": { "title": "Key Selling Points", "desc": "See your brand's strengths." },
"persona": { "title": "Target Customer Types", "desc": "AI analyzed what kind of customers visit this brand." },
"keywords": { "title": "Recommended Keywords", "desc": "Keywords your customers are likely to search for." },
"generate": { "title": "Generate Content", "desc": "Create video content based on the analysis.\nClick to sign in with Kakao and continue." }
},
"asset": { "asset": {
"image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." }, "image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." },
"upload": { "title": "Add Images", "desc": "You can freely add more images." }, "upload": { "title": "Add Images", "desc": "You can freely add more images." },
@ -234,6 +230,8 @@
"lyricsHint": "Select the lyrics area to edit", "lyricsHint": "Select the lyrics area to edit",
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.", "lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
"generateButton": "Generate Sound", "generateButton": "Generate Sound",
"regenerateButton": "Regenerate",
"regenerateHint": "Press the regenerate button to create new lyrics and music.",
"generating": "Generating...", "generating": "Generating...",
"generateVideo": "Generate Video", "generateVideo": "Generate Video",
"videoGenerating": "Generating Video", "videoGenerating": "Generating Video",

View File

@ -14,7 +14,10 @@
"defaultUser": "사용자", "defaultUser": "사용자",
"loggingOut": "로그아웃 중...", "loggingOut": "로그아웃 중...",
"logout": "로그아웃", "logout": "로그아웃",
"tutorialRestart": "튜토리얼 다시 보기" "tutorialRestart": "튜토리얼 다시 보기",
"tutorial": "튜토리얼",
"tutorialOn": "튜토리얼 켜기",
"tutorialOff": "튜토리얼 끄기"
}, },
"tutorial": { "tutorial": {
"skip": "건너뛰기", "skip": "건너뛰기",
@ -27,13 +30,6 @@
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." }, "field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." } "button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
}, },
"analysis": {
"identity": { "title": "브랜드 정체성", "desc": "AI가 분석한 펜션의 핵심 가치와 시장 포지셔닝을 확인하세요." },
"selling": { "title": "주요 셀링 포인트", "desc": "펜션의 강점을 확인할 수 있어요." },
"persona": { "title": "주요 고객 유형", "desc": "어떤 고객이 이 펜션을 찾는지 분석했어요." },
"keywords": { "title": "추천 타겟 키워드", "desc": "고객이 검색할 가능성이 높은 키워드들이에요." },
"generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." }
},
"asset": { "asset": {
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." }, "image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." }, "upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
@ -50,7 +46,7 @@
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." } "video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
}, },
"completion": { "completion": {
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 제목,장르,규격,가사를 확인하세요." }, "contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 파일명, 장르, 규격, 가사를 확인하세요." },
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." }, "generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" }, "completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." } "myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
@ -228,12 +224,14 @@
"genreLabel": "장르 선택", "genreLabel": "장르 선택",
"genreAuto": "자동 선택", "genreAuto": "자동 선택",
"genreBallad": "발라드", "genreBallad": "발라드",
"languageLabel": "언어", "languageLabel": "언어 선택",
"languageKorean": "한국어", "languageKorean": "한국어",
"lyricsColumn": "가사", "lyricsColumn": "가사",
"lyricsHint": "가사 영역을 선택해서 수정 가능해요", "lyricsHint": "가사 영역을 선택해서 수정 가능해요",
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.", "lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
"generateButton": "사운드 생성", "generateButton": "사운드 생성",
"regenerateButton": "재생성 하기",
"regenerateHint": "재생성 버튼을 누르면 가사와 음악을 다시 만들 수 있어요.",
"generating": "생성 중...", "generating": "생성 중...",
"generateVideo": "영상 생성하기", "generateVideo": "영상 생성하기",
"videoGenerating": "영상 생성 중", "videoGenerating": "영상 생성 중",

View File

@ -1,9 +1,26 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const LoadingSection: React.FC = () => { const LoadingSection: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [progress, setProgress] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
clearInterval(interval);
return 100;
}
// 느리게 올라가다가 90% 이후 거의 멈춤
const increment = prev < 50 ? 1 : prev < 85 ? 0.5 : 0.1;
return Math.min(prev + increment, 100);
});
}, 100);
return () => clearInterval(interval);
}, []);
return ( return (
<div className="loading-container"> <div className="loading-container">
<div className="loading-content"> <div className="loading-content">
@ -23,6 +40,16 @@ const LoadingSection: React.FC = () => {
className="loading-spinner-icon" className="loading-spinner-icon"
/> />
</div> </div>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(progress)}%</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -369,6 +369,15 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</div> </div>
</div> </div>
<p className="comp2-loading-text">{statusMessage}</p> <p className="comp2-loading-text">{statusMessage}</p>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${renderProgress}%` }}
/>
</div>
<span className="loading-progress-text">{renderProgress}%</span>
</div>
</div> </div>
) : videoStatus === 'error' ? ( ) : videoStatus === 'error' ? (
<div className="comp2-video-error"> <div className="comp2-video-error">
@ -397,7 +406,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
{/* 오른쪽: 콘텐츠 정보 */} {/* 오른쪽: 콘텐츠 정보 */}
<div className="comp2-info-section"> <div className="comp2-info-section">
<div className="comp2-info-header"> <div className="comp2-info-header">
<span className="comp2-info-label">{t('completion.contentInfo', { defaultValue: '콘텐츠 정보' })}</span> <span className="comp2-info-label">{t('completion.contentInfo', { defaultValue: '파일명' })}</span>
</div> </div>
<div className="comp2-info-content"> <div className="comp2-info-content">
<div className="comp2-file-info"> <div className="comp2-file-info">

View File

@ -519,23 +519,22 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return null; return null;
}; };
const handleTutorialRestart = () => {
tutorial.showRestartPopup(getCurrentTutorialKey()!);
};
const tutorialUI = ( const tutorialUI = (
<> <>
{getCurrentTutorialKey() && ( {getCurrentTutorialKey() && (
<button <button
className="tutorial-restart-fab" className={`tutorial-toggle-fab ${tutorial.isEnabled ? 'active' : ''}`}
onClick={handleTutorialRestart} onClick={() => tutorial.toggleTutorial(getCurrentTutorialKey())}
title={t('sidebar.tutorialRestart')} title={tutorial.isEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<path d="M12 8v4l3 3"/> <path d="M12 8v4l3 3"/>
</svg> </svg>
<span>{t('sidebar.tutorialRestart')}</span> <span>{t('sidebar.tutorial')}</span>
<span className={`tutorial-toggle-badge ${tutorial.isEnabled ? 'on' : 'off'}`}>
{tutorial.isEnabled ? 'ON' : 'OFF'}
</span>
</button> </button>
)} )}
{tutorial.isActive && ( {tutorial.isActive && (

View File

@ -60,11 +60,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
const [songTaskId, setSongTaskId] = useState<string | null>(null); const [songTaskId, setSongTaskId] = useState<string | null>(null);
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const languageDropdownRef = useRef<HTMLDivElement>(null);
// 완료 데이터를 localStorage에 저장 // 완료 데이터를 localStorage에 저장
const saveSongCompletionData = () => { const saveSongCompletionData = () => {
@ -78,22 +75,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
}; };
// Close language dropdown when clicking outside // Close language dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (languageDropdownRef.current && !languageDropdownRef.current.contains(event.target as Node)) {
setIsLanguageDropdownOpen(false);
}
};
if (isLanguageDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isLanguageDropdownOpen]);
// Auto-navigate to next page when video generation is complete // Auto-navigate to next page when video generation is complete
useEffect(() => { useEffect(() => {
if (videoGenerationStatus === 'complete' && songTaskId) { if (videoGenerationStatus === 'complete' && songTaskId) {
@ -494,39 +475,31 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Language Selection */} {/* Language Selection */}
<div className="sound-studio-section"> <div className="sound-studio-section">
<label className="input-label">{t('soundStudio.languageLabel')}</label> <label className="input-label">{t('soundStudio.languageLabel')}</label>
<div className="language-selector-wrapper" ref={languageDropdownRef}> <div className="genre-grid language-grid">
<button <div className="genre-row">
onClick={() => setIsLanguageDropdownOpen(!isLanguageDropdownOpen)} {['한국어', 'English', '中文'].map((lang) => (
disabled={isGenerating} <button
className="language-selector" key={lang}
> onClick={() => !isGenerating && setSelectedLang(lang)}
<div className="language-display"> disabled={isGenerating}
<span className="language-flag">{LANGUAGE_FLAGS[selectedLang]}</span> className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
<span className="language-name">{selectedLang}</span> >
</div> <span>{LANGUAGE_FLAGS[lang]}</span> {lang}
<img </button>
src="/assets/images/icon-dropdown.svg" ))}
alt="" </div>
className={`language-dropdown-icon ${isLanguageDropdownOpen ? 'open' : ''}`} <div className="genre-row">
/> {['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
</button> <button
{isLanguageDropdownOpen && ( key={lang}
<div className="language-dropdown-menu"> onClick={() => !isGenerating && setSelectedLang(lang)}
{Object.keys(LANGUAGE_MAP).map((lang) => ( disabled={isGenerating}
<button className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
key={lang} >
onClick={() => { <span>{LANGUAGE_FLAGS[lang]}</span> {lang}
setSelectedLang(lang); </button>
setIsLanguageDropdownOpen(false); ))}
}} </div>
className={`language-dropdown-item ${selectedLang === lang ? 'active' : ''}`}
>
<span className="language-flag">{LANGUAGE_FLAGS[lang]}</span>
<span className="language-name">{lang}</span>
</button>
))}
</div>
)}
</div> </div>
</div> </div>
@ -539,6 +512,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</svg> </svg>
{statusMessage} {statusMessage}
</div> </div>
) : status === 'complete' ? (
<>
<button
onClick={handleRegenerate}
className="btn-generate-sound"
>
{t('soundStudio.regenerateButton')}
</button>
<p className="regenerate-hint">{t('soundStudio.regenerateHint')}</p>
</>
) : ( ) : (
<button <button
onClick={handleGenerateMusic} onClick={handleGenerateMusic}
@ -560,7 +543,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<div className="lyrics-column"> <div className="lyrics-column">
<div className="lyrics-header"> <div className="lyrics-header">
<h3 className="column-title">{t('soundStudio.lyricsColumn')}</h3> <h3 className="column-title">{t('soundStudio.lyricsColumn')}</h3>
<p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</p> {/* <p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</p> */}
</div> </div>
{/* Audio Player */} {/* Audio Player */}