diff --git a/index.css b/index.css
index 31a5adb..23927f6 100644
--- a/index.css
+++ b/index.css
@@ -3932,7 +3932,7 @@
flex-direction: column;
align-items: center;
text-align: center;
- max-width: 500px;
+ max-width: 400px;
width: 100%;
}
@@ -4128,10 +4128,10 @@
.url-input-autocomplete-dropdown {
position: absolute;
- top: calc(100% + 8px);
+ top: calc(100% + 4px);
left: 0;
right: 0;
- background-color: #002224;
+ background-color: #01393B;
border: 1px solid rgba(148, 251, 224, 0.2);
border-radius: 12px;
max-height: 300px;
@@ -4247,7 +4247,7 @@
display: flex;
flex-direction: column;
align-items: center;
- z-index: 10;
+ z-index: 20;
position: relative;
margin-top: 50px;
}
@@ -4355,6 +4355,7 @@
gap: 10px;
border-radius: 999px;
background: var(--Color-white, #FFF);
+ position: relative;
}
.hero-input-wrapper.focused {
@@ -4446,7 +4447,7 @@
.hero-dropdown-menu {
position: absolute;
- top: calc(100% + 8px);
+ top: calc(100% + 4px);
left: 0;
min-width: 100px;
background-color: #ffffff;
@@ -4484,15 +4485,14 @@
/* Hero Autocomplete */
.hero-input-container {
flex: 1;
- position: relative;
min-width: 0;
}
.hero-autocomplete-dropdown {
position: absolute;
- top: calc(100% + 8px);
- left: -24px;
- right: -60px;
+ top: calc(100% + 4px);
+ left: 90px;
+ right: 0;
background-color: #ffffff;
border: 1px solid #E5E7EB;
border-radius: 12px;
@@ -7710,6 +7710,10 @@
border-color: var(--color-mint);
}
+.sound-type-btn.active:hover:not(:disabled) {
+ border-color: var(--color-mint);
+}
+
.sound-type-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -8297,8 +8301,20 @@
}
@media (min-width: 768px) {
- .asset-sticky-footer {
- left: 240px;
+ body:has(.sidebar.expanded) .asset-sticky-footer {
+ left: 15rem;
+ right: 0;
+ width: auto;
+ display: flex;
+ justify-content: center;
+ }
+
+ body:has(.sidebar.collapsed) .asset-sticky-footer {
+ left: 5rem;
+ right: 0;
+ width: auto;
+ display: flex;
+ justify-content: center;
}
}
diff --git a/src/App.tsx b/src/App.tsx
index ff628aa..279fe8d 100755
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -13,6 +13,7 @@ import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
import SocialConnectError from './pages/Social/SocialConnectError';
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
+import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
import { CrawlingResponse } from './types/api';
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
@@ -96,6 +97,7 @@ const App: React.FC = () => {
parseSavedAnalysisData()
);
const [error, setError] = useState(null);
+ const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [scrollProgress, setScrollProgress] = useState(0);
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
@@ -256,6 +258,7 @@ const App: React.FC = () => {
if (!url.trim()) return;
setViewMode('loading');
+ setIsAnalysisComplete(false);
setError(null);
try {
@@ -268,7 +271,8 @@ const App: React.FC = () => {
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
- setViewMode('analysis');
+ saveSearchHistory({ type: 'url', value: url });
+ setIsAnalysisComplete(true);
} catch (err) {
console.error('Crawling failed:', err);
const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
@@ -280,6 +284,7 @@ const App: React.FC = () => {
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
setViewMode('loading');
+ setIsAnalysisComplete(false);
setError(null);
try {
@@ -292,10 +297,12 @@ const App: React.FC = () => {
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
- setViewMode('analysis');
+ saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
+ setIsAnalysisComplete(true);
} catch (err) {
console.error('Autocomplete failed:', err);
- const errorMessage = err instanceof Error ? err.message : t('app.autocompleteGeneralError');
+ const is404 = err instanceof Error && err.message.includes('status: 404');
+ const errorMessage = is404 ? t('app.autocompleteError') : (err instanceof Error ? err.message : t('app.autocompleteGeneralError'));
setError(errorMessage);
setViewMode('landing');
}
@@ -395,7 +402,12 @@ const App: React.FC = () => {
}
if (viewMode === 'loading') {
- return ;
+ return (
+ setViewMode('analysis')}
+ />
+ );
}
if (viewMode === 'analysis' && analysisData) {
diff --git a/src/components/SearchHistory/SearchHistoryDropdown.tsx b/src/components/SearchHistory/SearchHistoryDropdown.tsx
new file mode 100644
index 0000000..3829e5e
--- /dev/null
+++ b/src/components/SearchHistory/SearchHistoryDropdown.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import { SearchHistoryItem } from './useSearchHistory';
+
+interface SearchHistoryDropdownProps {
+ items: SearchHistoryItem[];
+ onSelect: (item: SearchHistoryItem) => void;
+ onDelete: (e: React.MouseEvent, value: string) => void;
+ className?: string;
+ itemClassName?: string;
+ titleClassName?: string;
+ addressClassName?: string;
+}
+
+const SearchHistoryDropdown: React.FC = ({
+ items,
+ onSelect,
+ onDelete,
+ className = 'url-input-autocomplete-dropdown',
+ itemClassName = 'url-input-autocomplete-item',
+ titleClassName = 'url-input-autocomplete-title',
+ addressClassName = 'url-input-autocomplete-address',
+}) => {
+ if (items.length === 0) return null;
+
+ return (
+
+ {items.map((item, index) => (
+
+
+
+
+ ))}
+
+ );
+};
+
+export default SearchHistoryDropdown;
diff --git a/src/components/SearchHistory/useSearchHistory.ts b/src/components/SearchHistory/useSearchHistory.ts
new file mode 100644
index 0000000..2e5ead2
--- /dev/null
+++ b/src/components/SearchHistory/useSearchHistory.ts
@@ -0,0 +1,56 @@
+import { useState, useEffect } from 'react';
+
+export const SEARCH_HISTORY_KEY = 'castad_search_history';
+export const SEARCH_HISTORY_MAX = 5;
+
+export interface SearchHistoryItem {
+ type: 'url' | 'name';
+ value: string;
+ address?: string;
+ roadAddress?: string;
+}
+
+export const saveSearchHistory = (item: SearchHistoryItem) => {
+ try {
+ const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
+ const history: SearchHistoryItem[] = saved ? JSON.parse(saved) : [];
+ const filtered = history.filter(h => h.value !== item.value);
+ const updated = [item, ...filtered].slice(0, SEARCH_HISTORY_MAX);
+ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
+ } catch { /* ignore */ }
+};
+
+export const useSearchHistory = (searchType: 'url' | 'name') => {
+ const [history, setHistory] = useState([]);
+ const [showHistory, setShowHistory] = useState(false);
+
+ useEffect(() => {
+ try {
+ const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
+ if (saved) setHistory(JSON.parse(saved));
+ } catch { /* ignore */ }
+ }, []);
+
+ const filteredHistory = history.filter(h => h.type === searchType);
+
+ const deleteItem = (value: string) => {
+ const updated = history.filter(h => h.value !== value);
+ setHistory(updated);
+ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
+ if (updated.filter(h => h.type === searchType).length === 0) {
+ setShowHistory(false);
+ }
+ };
+
+ const openHistory = (inputValue: string) => {
+ if (!inputValue && filteredHistory.length > 0) setShowHistory(true);
+ };
+
+ const closeHistory = () => setShowHistory(false);
+
+ const hideOnInput = (value: string) => {
+ if (value) setShowHistory(false);
+ };
+
+ return { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem };
+};
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 9cab585..08ea9ed 100755
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -85,7 +85,7 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: },
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: },
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: },
- { id: '콘텐츠 캘린더', label: '콘텐츠 캘린더', disabled: false, icon: },
+ { id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: },
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: },
];
@@ -200,12 +200,12 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI
target="_blank"
rel="noopener noreferrer"
className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`}
- title="고객의견"
+ title={t('sidebar.inquiry')}
>
- {!isCollapsed && 고객의견}
+ {!isCollapsed && {t('sidebar.inquiry')}}
diff --git a/src/locales/en.json b/src/locales/en.json
index 4b955ea..35e726b 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -17,7 +17,8 @@
"tutorialRestart": "Restart Tutorial",
"tutorial": "Tutorial",
"tutorialOn": "Enable Tutorial",
- "tutorialOff": "Disable Tutorial"
+ "tutorialOff": "Disable Tutorial",
+ "inquiry": "Feedback"
},
"tutorial": {
"skip": "Skip",
@@ -37,7 +38,7 @@
"next": { "title": "Next Step", "desc": "Proceed to the next step when ready." }
},
"sound": {
- "genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand." },
+ "genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music in your chosen genre and language." },
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
@@ -290,7 +291,17 @@
"videoNotReady": "The video is not ready yet.",
"youtubeUploadMessage": "Uploading video to YouTube channel \"{{channelName}}\".\n\n(Upload feature coming soon)",
"deployComingSoon": "The deployment feature for the selected social channel is coming soon.",
- "youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
+ "youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page.",
+ "contentComplete": "Content Creation Complete",
+ "contentCompleteDesc": "Your optimized content has been created through AI analysis and editing",
+ "contentInfo": "File Name",
+ "genre": "Genre",
+ "resolution": "Resolution",
+ "lyrics": "Lyrics",
+ "sampleLyrics": "Loading lyrics...",
+ "downloading": "Downloading...",
+ "download": "Download",
+ "uploadToSocial": "Upload to Social"
},
"dashboard": {
"title": "Dashboard",
@@ -363,7 +374,38 @@
"stayIntroReel": "Stay Meomum Introduction Reel",
"newYearEvent": "New Year Special Event",
"nightTimelapse": "Pension Night View Timelapse"
- }
+ },
+ "metricTotalViews": "Views",
+ "metricWatchTime": "Watch Time",
+ "metricAvgViewDuration": "Avg. Watch Duration",
+ "metricNewSubscribers": "New Subscribers",
+ "metricUploadedVideos": "Uploaded Videos",
+ "unitHours": "h",
+ "unitMinutes": "m",
+ "noDataInPeriod": "No data available for this period.",
+ "dataLoading": "Loading data...",
+ "noUploadsTitle": "No uploaded videos yet.",
+ "noUploadsDesc": "Upload videos via ADO2 to see real stats.",
+ "reconnectButton": "Reconnect →",
+ "connectButton": "Connect →",
+ "retryButton": "Retry →",
+ "selectAccount": "Select an account",
+ "modeAnnual": "Annual",
+ "modeMonthly": "Monthly",
+ "chartThisYear": "This Year",
+ "chartLastYear": "Last Year",
+ "chartThisMonth": "This Month",
+ "chartLastMonth": "Last Month",
+ "monthOverMonth": "Month-over-Month Growth",
+ "noAudienceData": "Not enough data to show real audience insights.",
+ "mockContentNotice": "The content shown below is sample data, not your actual content.",
+ "dataDelayTitle": "Data is being prepared",
+ "dataDelayDesc": "According to YouTube's policy, analytics data may take up to 48 hours to be reflected after a video is uploaded. Please check back later.",
+ "sampleHide": "Hide Sample",
+ "sampleShow": "Show Sample",
+ "recentVideosDesc": "Showing stats for the latest 30 videos uploaded via ADO2.",
+ "channelStatsDesc": "Showing stats for the selected channel.",
+ "youtubeApiFailed": "Failed to call YouTube Analytics API."
},
"myInfo": {
"title": "My Info",
@@ -444,6 +486,36 @@
"required": "*",
"unknown": "Unknown"
},
+ "contentCalendar": {
+ "title": "Content Calendar",
+ "tabs": {
+ "all": "All",
+ "completed": "Completed",
+ "scheduled": "Scheduled",
+ "failed": "Failed"
+ },
+ "status": {
+ "completed": "Completed",
+ "scheduled": "Scheduled",
+ "failed": "Failed"
+ },
+ "months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
+ "days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
+ "yearMonth": "{{month}} {{year}}",
+ "monthDay": "{{month}} {{day}}",
+ "loading": "Loading...",
+ "noResults": "No recent results",
+ "noResultsDesc": "Upload your content to social channels",
+ "ado2Contents": "ADO2 Contents",
+ "cancel": "Cancel",
+ "retry": "Retry",
+ "cancelConfirm": "Are you sure you want to cancel this reservation?",
+ "cancelFailed": "Failed to cancel.",
+ "retryFailed": "Failed to retry.",
+ "completedCount": "Done {{count}}",
+ "scheduledCount": "Sched {{count}}",
+ "failedCount": "Fail {{count}}"
+ },
"app": {
"loginProcessing": "Processing login...",
"loginFailed": "Login processing failed. Please try again.",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index cfa2ab0..35b8cb4 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -17,7 +17,8 @@
"tutorialRestart": "튜토리얼 다시 보기",
"tutorial": "튜토리얼",
"tutorialOn": "튜토리얼 켜기",
- "tutorialOff": "튜토리얼 끄기"
+ "tutorialOff": "튜토리얼 끄기",
+ "inquiry": "고객의견"
},
"tutorial": {
"skip": "건너뛰기",
@@ -37,7 +38,7 @@
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
},
"sound": {
- "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." },
+ "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요.", "note": "배경음악은 이후 오픈 예정입니다." },
"language": { "title": "언어 선택", "desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
"generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."},
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
@@ -290,7 +291,17 @@
"videoNotReady": "영상이 아직 준비되지 않았습니다.",
"youtubeUploadMessage": "YouTube 채널 \"{{channelName}}\"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)",
"deployComingSoon": "선택한 소셜 채널에 배포 기능이 준비 중입니다.",
- "youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
+ "youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.",
+ "contentComplete": "콘텐츠 제작 완료",
+ "contentCompleteDesc": "AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다",
+ "contentInfo": "파일명",
+ "genre": "장르",
+ "resolution": "규격",
+ "lyrics": "가사",
+ "sampleLyrics": "가사를 불러오는 중...",
+ "downloading": "다운로드 중...",
+ "download": "다운로드",
+ "uploadToSocial": "소셜 채널 업로드"
},
"dashboard": {
"title": "대시보드",
@@ -363,7 +374,38 @@
"stayIntroReel": "스테이 머뭄 소개 릴스",
"newYearEvent": "신년 특가 이벤트 안내",
"nightTimelapse": "펜션 야경 타임랩스"
- }
+ },
+ "metricTotalViews": "조회수",
+ "metricWatchTime": "시청시간",
+ "metricAvgViewDuration": "평균 시청시간",
+ "metricNewSubscribers": "신규 구독자",
+ "metricUploadedVideos": "업로드 영상",
+ "unitHours": "시간",
+ "unitMinutes": "분",
+ "noDataInPeriod": "이 기간에 데이터가 없습니다.",
+ "dataLoading": "데이터 로딩 중...",
+ "noUploadsTitle": "아직 업로드된 영상이 없습니다.",
+ "noUploadsDesc": "ADO2에서 영상을 업로드하면 실제 통계가 표시됩니다.",
+ "reconnectButton": "재연동하러 가기 →",
+ "connectButton": "연동하러 가기 →",
+ "retryButton": "재시도 →",
+ "selectAccount": "계정을 선택하세요",
+ "modeAnnual": "연간",
+ "modeMonthly": "월간",
+ "chartThisYear": "올해",
+ "chartLastYear": "작년",
+ "chartThisMonth": "이번달",
+ "chartLastMonth": "지난달",
+ "monthOverMonth": "전월 대비 성장",
+ "noAudienceData": "누적 데이터가 부족하여 실제 시청자 정보가 없습니다.",
+ "mockContentNotice": "현재 표시된 콘텐츠는 실제 데이터가 아닌 샘플 데이터입니다.",
+ "dataDelayTitle": "데이터 준비 중",
+ "dataDelayDesc": "유튜브 정책에 따라 분석 데이터는 영상 업로드 후 최대 48시간이 지나야 반영됩니다. 나중에 다시 확인해 주세요.",
+ "sampleHide": "Sample 숨기기",
+ "sampleShow": "Sample 보기",
+ "recentVideosDesc": "ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.",
+ "channelStatsDesc": "선택한 채널의 통계가 표시됩니다.",
+ "youtubeApiFailed": "YouTube Analytics API 호출에 실패했습니다."
},
"myInfo": {
"title": "내 정보",
@@ -444,6 +486,36 @@
"required": "*",
"unknown": "알 수 없음"
},
+ "contentCalendar": {
+ "title": "콘텐츠 캘린더",
+ "tabs": {
+ "all": "전체",
+ "completed": "완료",
+ "scheduled": "예약",
+ "failed": "실패"
+ },
+ "status": {
+ "completed": "완료",
+ "scheduled": "예약",
+ "failed": "실패"
+ },
+ "months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
+ "days": ["일","월","화","수","목","금","토"],
+ "yearMonth": "{{year}}년 {{month}}",
+ "monthDay": "{{month}}월 {{day}}",
+ "loading": "불러오는 중...",
+ "noResults": "최근 결과 없음",
+ "noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
+ "ado2Contents": "ADO2 콘텐츠",
+ "cancel": "취소",
+ "retry": "재시도",
+ "cancelConfirm": "예약을 취소하시겠습니까?",
+ "cancelFailed": "취소에 실패했습니다.",
+ "retryFailed": "재시도에 실패했습니다.",
+ "completedCount": "완료 {{count}}",
+ "scheduledCount": "예약 {{count}}",
+ "failedCount": "실패 {{count}}"
+ },
"app": {
"loginProcessing": "로그인 처리 중...",
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
diff --git a/src/pages/Analysis/AnalysisResultSection.tsx b/src/pages/Analysis/AnalysisResultSection.tsx
index 3087787..7c82373 100755
--- a/src/pages/Analysis/AnalysisResultSection.tsx
+++ b/src/pages/Analysis/AnalysisResultSection.tsx
@@ -1,4 +1,3 @@
-
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CrawlingResponse, TargetPersona } from '../../types/api';
diff --git a/src/pages/Analysis/LoadingSection.tsx b/src/pages/Analysis/LoadingSection.tsx
index d411eb0..a71722c 100755
--- a/src/pages/Analysis/LoadingSection.tsx
+++ b/src/pages/Analysis/LoadingSection.tsx
@@ -1,26 +1,43 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-const LoadingSection: React.FC = () => {
+interface LoadingSectionProps {
+ onComplete?: () => void;
+ isComplete?: boolean;
+}
+
+const LoadingSection: React.FC = ({ onComplete, isComplete }) => {
const { t } = useTranslation();
const [progress, setProgress] = useState(0);
+ const intervalRef = useRef | null>(null);
useEffect(() => {
- const interval = setInterval(() => {
+ intervalRef.current = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
- clearInterval(interval);
+ if (intervalRef.current) clearInterval(intervalRef.current);
return 100;
}
- // 느리게 올라가다가 90% 이후 거의 멈춤
- const increment = prev < 50 ? 1 : prev < 85 ? 0.5 : 0.1;
+ const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
return Math.min(prev + increment, 100);
});
}, 100);
- return () => clearInterval(interval);
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
}, []);
+ useEffect(() => {
+ if (!isComplete) return;
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ setProgress(100);
+ const timer = setTimeout(() => {
+ onComplete?.();
+ }, 400);
+ return () => clearTimeout(timer);
+ }, [isComplete]);
+
return (
diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx
index a676b5c..ce1133d 100755
--- a/src/pages/Dashboard/CompletionContent.tsx
+++ b/src/pages/Dashboard/CompletionContent.tsx
@@ -40,7 +40,9 @@ const CompletionContent: React.FC = ({
const [errorMessage, setErrorMessage] = useState(null);
const [statusMessage, setStatusMessage] = useState('');
const [renderProgress, setRenderProgress] = useState(0);
+ const [displayProgress, setDisplayProgress] = useState(0);
const hasStartedGeneration = useRef(false);
+ const displayIntervalRef = useRef | null>(null);
const tutorial = useTutorial();
@@ -54,7 +56,7 @@ const CompletionContent: React.FC = ({
} else if (isComplete && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
}
- }, [videoStatus, tutorial]);
+ }, [videoStatus, tutorial, tutorial.isActive]);
// 소셜 미디어 포스팅 모달
const [showSocialModal, setShowSocialModal] = useState(false);
@@ -83,6 +85,31 @@ const CompletionContent: React.FC = ({
}
}, [renderProgress, onVideoProgressChange]);
+ useEffect(() => {
+ if (displayIntervalRef.current) clearInterval(displayIntervalRef.current);
+
+ if (renderProgress === 100) {
+ setDisplayProgress(100);
+ return;
+ }
+
+ // renderProgress에 도달한 후에도 99%까지 서서히 크리핑
+ const CREEP_MAX = 99;
+
+ displayIntervalRef.current = setInterval(() => {
+ setDisplayProgress(prev => {
+ const target = Math.max(prev, renderProgress);
+ if (prev >= CREEP_MAX) return prev;
+ const increment = prev < 70 ? 0.2 : prev < 90 ? 0.04 : 0.01;
+ return Math.min(prev + increment, Math.max(target, Math.min(prev + increment, CREEP_MAX)));
+ });
+ }, 100);
+
+ return () => {
+ if (displayIntervalRef.current) clearInterval(displayIntervalRef.current);
+ };
+ }, [renderProgress]);
+
const saveToStorage = (videoTaskId: string, currentSongTaskId: string, status: VideoStatus, url: string | null, dbId?: number) => {
const data: SavedVideoState = {
videoTaskId,
@@ -254,6 +281,7 @@ const CompletionContent: React.FC = ({
setVideoUrl(completeVideo.videoUrl);
if (completeVideo.videoDbId) setVideoDbId(completeVideo.videoDbId);
setVideoStatus('complete');
+ setShowComplete(true);
hasStartedGeneration.current = true;
return;
}
@@ -265,6 +293,7 @@ const CompletionContent: React.FC = ({
setVideoUrl(savedState.videoUrl);
if (savedState.videoDbId) setVideoDbId(savedState.videoDbId);
setVideoStatus('complete');
+ setShowComplete(true);
hasStartedGeneration.current = true;
} else if (savedState.status === 'polling') {
setVideoStatus('polling');
@@ -296,15 +325,32 @@ const CompletionContent: React.FC = ({
}
}, []);
- const handleDownload = () => {
- if (videoUrl) {
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const handleDownload = async () => {
+ if (!videoUrl || isDownloading) return;
+ setIsDownloading(true);
+ try {
+ const response = await fetch(videoUrl);
+ const blob = await response.blob();
+ const blobUrl = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = blobUrl;
+ link.download = getFileName();
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(blobUrl);
+ } catch {
const link = document.createElement('a');
link.href = videoUrl;
- link.download = 'castad_video.mp4';
+ link.download = getFileName();
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
+ } finally {
+ setIsDownloading(false);
}
};
@@ -321,11 +367,20 @@ const CompletionContent: React.FC = ({
setVideoStatus('idle');
setVideoUrl(null);
setErrorMessage(null);
+ setShowComplete(false);
clearStorage();
startVideoGeneration();
};
- const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
+ const [showComplete, setShowComplete] = useState(false);
+
+ useEffect(() => {
+ if (displayProgress < 100) return;
+ const timer = setTimeout(() => setShowComplete(true), 500);
+ return () => clearTimeout(timer);
+ }, [displayProgress]);
+
+ const isLoading = videoStatus === 'generating' || videoStatus === 'polling' || (videoStatus === 'complete' && !showComplete);
// 비디오 해상도 계산
const getVideoResolution = () => {
@@ -351,8 +406,8 @@ const CompletionContent: React.FC = ({
-
{t('completion.contentComplete', { defaultValue: '콘텐츠 제작 완료' })}
-
{t('completion.contentCompleteDesc', { defaultValue: 'AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다' })}
+
{t('completion.contentComplete')}
+
{t('completion.contentCompleteDesc')}
@@ -373,10 +428,10 @@ const CompletionContent: React.FC
= ({
- {renderProgress}%
+ {Math.floor(displayProgress)}%
) : videoStatus === 'error' ? (
@@ -406,7 +461,7 @@ const CompletionContent: React.FC = ({
{/* 오른쪽: 콘텐츠 정보 */}
- {t('completion.contentInfo', { defaultValue: '파일명' })}
+ {t('completion.contentInfo')}
@@ -415,17 +470,17 @@ const CompletionContent: React.FC = ({
- {t('completion.genre', { defaultValue: '장르' })} : {songCompletionData?.genre || 'K-POP'}
+ {t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}
- {t('completion.resolution', { defaultValue: '규격' })} : {getVideoResolution()}
+ {t('completion.resolution')} : {getVideoResolution()}
-
{t('completion.lyrics', { defaultValue: '가사' })}
+
{t('completion.lyrics')}
- {songCompletionData?.lyrics || t('completion.sampleLyrics', { defaultValue: '가사를 불러오는 중...' })}
+ {songCompletionData?.lyrics || t('completion.sampleLyrics')}
@@ -434,17 +489,17 @@ const CompletionContent: React.FC
= ({
diff --git a/src/pages/Dashboard/ContentCalendarContent.tsx b/src/pages/Dashboard/ContentCalendarContent.tsx
index 7e4b20c..522afd7 100644
--- a/src/pages/Dashboard/ContentCalendarContent.tsx
+++ b/src/pages/Dashboard/ContentCalendarContent.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
import { UploadHistoryItem } from '../../types/api';
import { getUploadHistory, cancelUpload, retryUpload } from '../../utils/api';
@@ -53,13 +54,13 @@ const statusColor = (status: UploadStatus) => {
}
};
-// 상태 라벨
-const statusLabel = (status: UploadStatus) => {
+// 상태 라벨 (컴포넌트 내부에서 t()로 처리)
+const statusLabelKey = (status: UploadStatus) => {
switch (status) {
- case 'completed': return '완료';
+ case 'completed': return 'contentCalendar.status.completed';
case 'scheduled':
- case 'pending': return '예약';
- case 'failed': return '실패';
+ case 'pending': return 'contentCalendar.status.scheduled';
+ case 'failed': return 'contentCalendar.status.failed';
default: return status;
}
};
@@ -69,6 +70,7 @@ interface ContentCalendarContentProps {
}
const ContentCalendarContent: React.FC
= ({ onNavigate }) => {
+ const { t } = useTranslation();
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
@@ -203,7 +205,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
// 취소
const handleCancel = async (uploadId: number) => {
- if (!window.confirm('예약을 취소하시겠습니까?')) return;
+ if (!window.confirm(t('contentCalendar.cancelConfirm'))) return;
// 낙관적 업데이트: 먼저 UI에서 제거
setAllItems(prev => prev.filter(i => i.upload_id !== uploadId));
setPanelItems(prev => prev.filter(i => i.upload_id !== uploadId));
@@ -212,7 +214,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
} catch {
// 실패 시 다시 불러오기
refreshAll();
- alert('취소에 실패했습니다.');
+ alert(t('contentCalendar.cancelFailed'));
}
};
@@ -222,7 +224,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
await retryUpload(uploadId);
refreshAll();
} catch {
- alert('재시도에 실패했습니다.');
+ alert(t('contentCalendar.retryFailed'));
}
};
@@ -234,10 +236,11 @@ const ContentCalendarContent: React.FC = ({ onNavig
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
- // 날짜 표시 (M월 D)
+ // 날짜 표시
const formatDateLabel = (key: string) => {
const [, m, d] = key.split('-');
- return `${parseInt(m)}월 ${parseInt(d)}`;
+ const months = t('contentCalendar.months', { returnObjects: true }) as string[];
+ return t('contentCalendar.monthDay', { month: months[parseInt(m) - 1], day: parseInt(d) });
};
const buildCalendarDays = (): CalendarDay[] => {
@@ -266,9 +269,15 @@ const ContentCalendarContent: React.FC = ({ onNavig
};
const calendarDays = buildCalendarDays();
- const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
- const dayLabels = ['일','월','화','수','목','금','토'];
+ const monthNames = t('contentCalendar.months', { returnObjects: true }) as string[];
+ const dayLabels = t('contentCalendar.days', { returnObjects: true }) as string[];
const tabs: TabType[] = ['전체','완료','예약','실패'];
+ const tabLabels: Record = {
+ '전체': t('contentCalendar.tabs.all'),
+ '완료': t('contentCalendar.tabs.completed'),
+ '예약': t('contentCalendar.tabs.scheduled'),
+ '실패': t('contentCalendar.tabs.failed'),
+ };
// ── 캘린더 그리드 ──────────────────────────────────────────────
const renderCalendarGrid = () => {
@@ -388,7 +397,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
- 완료 {summary.completed}
+ {t('contentCalendar.completedCount', { count: summary.completed })}
)}
@@ -396,7 +405,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
- 예약 {summary.scheduled}
+ {t('contentCalendar.scheduledCount', { count: summary.scheduled })}
)}
@@ -404,7 +413,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
- 실패 {summary.failed}
+ {t('contentCalendar.failedCount', { count: summary.failed })}
)}
@@ -432,7 +441,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap',
}}
>
- {tab}
+ {tabLabels[tab]}
))}
@@ -442,9 +451,9 @@ const ContentCalendarContent: React.FC = ({ onNavig
const renderStats = () => (
{[
- { label: '예약', value: totalStats.scheduled },
- { label: '완료', value: totalStats.completed },
- { label: '실패', value: totalStats.failed },
+ { label: t('contentCalendar.tabs.scheduled'), value: totalStats.scheduled },
+ { label: t('contentCalendar.tabs.completed'), value: totalStats.completed },
+ { label: t('contentCalendar.tabs.failed'), value: totalStats.failed },
].map((item, idx) => (
{idx > 0 && }
@@ -483,7 +492,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px',
margin: 0, whiteSpace: 'nowrap',
}}>
- {year}년 {monthNames[month]}
+ {t('contentCalendar.yearMonth', { year, month: monthNames[month] })}
)}
{isFailed && (
@@ -588,7 +597,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
color: '#ffffff', cursor: 'pointer',
}}
>
- 재시도
+ {t('contentCalendar.retry')}
)}
@@ -602,7 +611,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
if (panelLoading) {
return (
- 불러오는 중...
+ {t('contentCalendar.loading')}
);
}
@@ -617,13 +626,13 @@ const ContentCalendarContent: React.FC = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 16,
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0,
}}>
- 최근 결과 없음
+ {t('contentCalendar.noResults')}
- 제작한 콘텐츠를 소셜 채널에 업로드해 보세요
+ {t('contentCalendar.noResultsDesc')}
);
@@ -681,7 +690,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}>
- 콘텐츠 캘린더
+ {t('contentCalendar.title')}
@@ -746,7 +755,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}>
- 콘텐츠 캘린더
+ {t('contentCalendar.title')}
@@ -774,7 +783,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
{loading ? (
- 불러오는 중...
+ {t('contentCalendar.loading')}
) : renderCalendarGrid()}
diff --git a/src/pages/Dashboard/DashboardContent.tsx b/src/pages/Dashboard/DashboardContent.tsx
index 056e6f1..37ed99e 100755
--- a/src/pages/Dashboard/DashboardContent.tsx
+++ b/src/pages/Dashboard/DashboardContent.tsx
@@ -81,29 +81,29 @@ interface ConnectedAccount {
// =====================================================
const MOCK_CONTENT_METRICS: ContentMetric[] = [
- { id: 'total-views', label: '조회수', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' },
- { id: 'total-watch-time', label: '시청시간', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' },
- { id: 'avg-view-duration',label: '평균 시청시간', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' },
- { id: 'new-subscribers', label: '신규 구독자', value: 483, unit: 'count', trend: 50, trendDirection: 'up' },
- { id: 'likes', label: '좋아요', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' },
- { id: 'comments', label: '댓글', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' },
- { id: 'shares', label: '공유', value: 840, unit: 'count', trend: 15, trendDirection: 'up' },
- { id: 'uploaded-videos', label: '업로드 영상', value: 17, unit: 'count', trend: 4, trendDirection: 'up' },
+ { id: 'total-views', label: 'dashboard.metricTotalViews', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' },
+ { id: 'total-watch-time', label: 'dashboard.metricWatchTime', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' },
+ { id: 'avg-view-duration',label: 'dashboard.metricAvgViewDuration', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' },
+ { id: 'new-subscribers', label: 'dashboard.metricNewSubscribers', value: 483, unit: 'count', trend: 50, trendDirection: 'up' },
+ { id: 'likes', label: 'dashboard.metricLikes', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' },
+ { id: 'comments', label: 'dashboard.metricComments', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' },
+ { id: 'shares', label: 'dashboard.metricShares', value: 840, unit: 'count', trend: 15, trendDirection: 'up' },
+ { id: 'uploaded-videos', label: 'dashboard.metricUploadedVideos', value: 17, unit: 'count', trend: 4, trendDirection: 'up' },
];
const MOCK_MONTHLY_DATA: MonthlyData[] = [
- { month: '1월', thisYear: 18000, lastYear: 14500 },
- { month: '2월', thisYear: 19500, lastYear: 15800 },
- { month: '3월', thisYear: 21000, lastYear: 17200 },
- { month: '4월', thisYear: 18500, lastYear: 16800 },
- { month: '5월', thisYear: 24000, lastYear: 19500 },
- { month: '6월', thisYear: 27500, lastYear: 21000 },
- { month: '7월', thisYear: 32000, lastYear: 23500 },
- { month: '8월', thisYear: 29500, lastYear: 24800 },
- { month: '9월', thisYear: 31000, lastYear: 26200 },
- { month: '10월', thisYear: 28500, lastYear: 25500 },
- { month: '11월', thisYear: 34000, lastYear: 27800 },
- { month: '12월', thisYear: 38000, lastYear: 29500 },
+ { month: 'dashboard.months.jan', thisYear: 18000, lastYear: 14500 },
+ { month: 'dashboard.months.feb', thisYear: 19500, lastYear: 15800 },
+ { month: 'dashboard.months.mar', thisYear: 21000, lastYear: 17200 },
+ { month: 'dashboard.months.apr', thisYear: 18500, lastYear: 16800 },
+ { month: 'dashboard.months.may', thisYear: 24000, lastYear: 19500 },
+ { month: 'dashboard.months.jun', thisYear: 27500, lastYear: 21000 },
+ { month: 'dashboard.months.jul', thisYear: 32000, lastYear: 23500 },
+ { month: 'dashboard.months.aug', thisYear: 29500, lastYear: 24800 },
+ { month: 'dashboard.months.sep', thisYear: 31000, lastYear: 26200 },
+ { month: 'dashboard.months.oct', thisYear: 28500, lastYear: 25500 },
+ { month: 'dashboard.months.nov', thisYear: 34000, lastYear: 27800 },
+ { month: 'dashboard.months.dec', thisYear: 38000, lastYear: 29500 },
];
const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => {
@@ -201,18 +201,16 @@ const formatNumber = (num: number): string => {
return num.toString();
};
-// unit에 따라 value를 표시용 문자열로 변환 (언어별 suffix는 호출부에서 처리)
-const formatValue = (value: number, unit: string): string => {
- if (unit === 'hours') return value.toFixed(1) + '시간';
- if (unit === 'minutes') return Math.round(value) + '분';
+const formatValue = (value: number, unit: string, unitHours: string, unitMinutes: string): string => {
+ if (unit === 'hours') return value.toFixed(1) + unitHours;
+ if (unit === 'minutes') return Math.round(value) + unitMinutes;
return formatNumber(Math.round(value));
};
-// unit에 따라 trend 절댓값을 표시용 문자열로 변환
-const formatTrend = (trend: number, unit: string): string => {
+const formatTrend = (trend: number, unit: string, unitHours: string, unitMinutes: string): string => {
const abs = Math.abs(trend);
- if (unit === 'hours') return abs.toFixed(1) + '시간';
- if (unit === 'minutes') return Math.round(abs) + '분';
+ if (unit === 'hours') return abs.toFixed(1) + unitHours;
+ if (unit === 'minutes') return Math.round(abs) + unitMinutes;
return formatNumber(Math.round(abs));
};
@@ -239,19 +237,22 @@ const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => (
);
const StatCard: React.FC = ({ label, value, unit, trend, trendDirection }) => {
+ const { t } = useTranslation();
+ const unitHours = t('dashboard.unitHours');
+ const unitMinutes = t('dashboard.unitMinutes');
const isNeutral = trend === 0 || trendDirection === '-';
const isUp = trendDirection === 'up';
return (
-
{label}
-
{formatValue(value, unit)}
+
{t(label)}
+
{formatValue(value, unit, unitHours, unitMinutes)}
{isNeutral ? (
─
) : (
- {isUp ? '+' : '-'}{formatTrend(trend, unit)}
+ {isUp ? '+' : '-'}{formatTrend(trend, unit, unitHours, unitMinutes)}
)}
@@ -276,11 +277,12 @@ const YearOverYearChart: React.FC<{
currentLabel: string;
previousLabel: string;
mode: 'day' | 'month';
-}> = ({ data, currentLabel, previousLabel, mode }) => {
+ noDataLabel: string;
+}> = ({ data, currentLabel, previousLabel, mode, noDataLabel }) => {
if (data.length === 0) {
return (
- 이 기간에 데이터가 없습니다.
+ {noDataLabel}
);
}
@@ -362,14 +364,14 @@ const YearOverYearChart: React.FC<{
// Icon Components
// =====================================================
-const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => (
-
+
+ )}
+
+ {/* 48시간 지연 안내 배너 */}
+ {dashboardData && !isEmptyState && !hasRealContentMetrics && !showMockData && (
+
+
+
+
+
+
+
+
{t('dashboard.dataDelayTitle')}
+
{t('dashboard.dataDelayDesc')}
@@ -679,7 +698,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => {
onClick={() => onNavigate?.('내 정보')}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
>
- 재연동하러 가기 →
+ {t('dashboard.reconnectButton')}
)}
{error.code === 'YOUTUBE_NOT_CONNECTED' && (
@@ -687,7 +706,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => {
onClick={() => onNavigate?.('내 정보')}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
>
- 연동하러 가기 →
+ {t('dashboard.connectButton')}
)}
{(error.code === 'YOUTUBE_API_FAILED' || error.code === 'DASHBOARD_DATA_ERROR') && (
@@ -695,7 +714,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => {
onClick={() => { setError(null); setRetryTrigger((n: number) => n + 1); }}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
>
- 재시도 →
+ {t('dashboard.retryButton')}
)}
@@ -738,7 +757,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => {
>
) : (
- 계정을 선택하세요
+ {t('dashboard.selectAccount')}
);
})()}
@@ -800,20 +819,20 @@ const DashboardContent: React.FC = ({ onNavigate }) => {
{t('dashboard.contentPerformance')}
-
ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.
+
{t('dashboard.recentVideosDesc')}
@@ -850,6 +869,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => {
currentLabel={chartCurrentLabel}
previousLabel={chartPreviousLabel}
mode={mode}
+ noDataLabel={t('dashboard.noDataInPeriod')}
/>
@@ -874,7 +894,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => {
{t('dashboard.audienceInsights')}
-
선택한 채널의 통계가 표시됩니다.
+
{t('dashboard.channelStatsDesc')}
@@ -907,7 +927,7 @@ const DashboardContent: React.FC
= ({ onNavigate }) => {
padding: '20px 28px',
}}>
- 누적 데이터가 부족하여 실제 시청자 정보가 없습니다.
+ {t('dashboard.noAudienceData')}
@@ -943,7 +963,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => {
: <>>
}
- {showMockData ? 'Sample 숨기기' : 'Sample 보기'}
+ {showMockData ? t('dashboard.sampleHide') : t('dashboard.sampleShow')}
);
diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx
index 05859ed..86103a2 100755
--- a/src/pages/Dashboard/GenerationFlow.tsx
+++ b/src/pages/Dashboard/GenerationFlow.tsx
@@ -24,6 +24,7 @@ const ACTIVE_ITEM_KEY = 'castad_active_item';
const SONG_TASK_ID_KEY = 'castad_song_task_id';
const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
+import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
const SONG_GENERATION_KEY = 'castad_song_generation';
@@ -114,6 +115,7 @@ const GenerationFlow: React.FC = ({
const [videoGenerationProgress, setVideoGenerationProgress] = useState(0);
const [analysisData, setAnalysisData] = useState(parseAnalysisData());
const [analysisError, setAnalysisError] = useState(null);
+ const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [userInfo, setUserInfo] = useState(null);
const tutorial = useTutorial();
@@ -208,6 +210,7 @@ const GenerationFlow: React.FC = ({
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
goToWizardStep(-1); // 로딩 상태로
+ setIsAnalysisComplete(false);
setAnalysisError(null);
try {
@@ -215,11 +218,11 @@ const GenerationFlow: React.FC = ({
console.log('[Autocomplete] Response m_id:', data.m_id);
// 기본값 보장
- if (data.marketing_analysis) {
- data.marketing_analysis.tags = data.marketing_analysis.tags || [];
- data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
- data.marketing_analysis.report = data.marketing_analysis.report || '';
- }
+ // if (data.marketing_analysis) {
+ // data.marketing_analysis.tags = data.marketing_analysis.tags || [];
+ // data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
+ // data.marketing_analysis.report = data.marketing_analysis.report || '';
+ // }
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
data.processed_info.region = data.processed_info.region || '';
@@ -229,7 +232,8 @@ const GenerationFlow: React.FC = ({
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
- goToWizardStep(0); // 브랜드 분석 결과로
+ saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
+ setIsAnalysisComplete(true);
} catch (err) {
console.error('Autocomplete error:', err);
setAnalysisError(err instanceof Error ? err.message : t('app.autocompleteError'));
@@ -275,6 +279,7 @@ const GenerationFlow: React.FC = ({
if (!url.trim()) return;
goToWizardStep(-1); // 로딩 상태로
+ setIsAnalysisComplete(false);
setAnalysisError(null);
try {
@@ -282,11 +287,11 @@ const GenerationFlow: React.FC = ({
console.log('[Crawl] Response m_id:', data.m_id);
// 기본값 보장
- if (data.marketing_analysis) {
- data.marketing_analysis.tags = data.marketing_analysis.tags || [];
- data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
- data.marketing_analysis.report = data.marketing_analysis.report || '';
- }
+ // if (data.marketing_analysis) {
+ // data.marketing_analysis.tags = data.marketing_analysis.tags || [];
+ // data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
+ // data.marketing_analysis.report = data.marketing_analysis.report || '';
+ // }
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
data.processed_info.region = data.processed_info.region || '';
@@ -296,7 +301,8 @@ const GenerationFlow: React.FC = ({
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
- goToWizardStep(0); // 브랜드 분석 결과로
+ saveSearchHistory({ type: 'url', value: url });
+ setIsAnalysisComplete(true);
} catch (err) {
console.error('Crawling failed:', err);
const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
@@ -384,7 +390,12 @@ const GenerationFlow: React.FC = ({
);
case -1:
// 로딩 단계
- return ;
+ return (
+ goToWizardStep(0)}
+ />
+ );
case 0:
// 브랜드 분석 결과 단계
if (!analysisData) {
diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx
index 3fb6ac4..fd87a52 100755
--- a/src/pages/Dashboard/SoundStudioContent.tsx
+++ b/src/pages/Dashboard/SoundStudioContent.tsx
@@ -409,14 +409,15 @@ const SoundStudioContent: React.FC = ({
{[
- { key: '보컬', label: t('soundStudio.soundTypeVocal') },
- { key: '배경음악', label: t('soundStudio.soundTypeBGM') },
- ].map(({ key, label }) => (
+ { key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false },
+ { key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: true },
+ ].map(({ key, label, disabled }) => (
@@ -586,7 +587,7 @@ const SoundStudioContent: React.FC
= ({
{lyrics ? (