크레딧기능 추가
parent
044fd21b2d
commit
bdd52ed992
55
index.css
55
index.css
|
|
@ -780,6 +780,16 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-credits {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sidebar Language Switch */
|
/* Sidebar Language Switch */
|
||||||
.sidebar-language-switch {
|
.sidebar-language-switch {
|
||||||
padding: 0 1rem 0.75rem;
|
padding: 0 1rem 0.75rem;
|
||||||
|
|
@ -7990,6 +8000,25 @@
|
||||||
resize: none;
|
resize: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #046266 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-textarea::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-textarea::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-textarea::-webkit-scrollbar-thumb {
|
||||||
|
background: #046266;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-textarea::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #379599;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-placeholder {
|
.lyrics-placeholder {
|
||||||
|
|
@ -8042,6 +8071,15 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.charge-credits-link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #AE72F9;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Video Generate Button */
|
/* Video Generate Button */
|
||||||
.btn-video-generate {
|
.btn-video-generate {
|
||||||
padding: 0.625rem 2.5rem;
|
padding: 0.625rem 2.5rem;
|
||||||
|
|
@ -8450,6 +8488,23 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.asset-image-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-image-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-image-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #067C80;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-image-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #088a8e;
|
||||||
|
}
|
||||||
|
|
||||||
/* Load More Button */
|
/* Load More Button */
|
||||||
.asset-load-more {
|
.asset-load-more {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,10 @@ interface SidebarProps {
|
||||||
onHome?: () => void;
|
onHome?: () => void;
|
||||||
userInfo?: UserMeResponse | null;
|
userInfo?: UserMeResponse | null;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
credits?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout }) => {
|
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||||
|
|
@ -180,6 +181,9 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
|
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
|
||||||
|
{credits !== null && credits !== undefined && (
|
||||||
|
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const activeAccounts = response.accounts?.filter(acc => acc.is_active) || [];
|
const activeAccounts = response.accounts?.filter(acc => acc.is_active) || [];
|
||||||
setSocialAccounts(activeAccounts);
|
setSocialAccounts(activeAccounts);
|
||||||
if (activeAccounts.length > 0) {
|
if (activeAccounts.length > 0) {
|
||||||
setSelectedChannel(activeAccounts[0].platform_user_id);
|
const lastUsed = localStorage.getItem('lastUsedSocialChannel');
|
||||||
|
const defaultAccount = activeAccounts.find(acc => acc.platform_user_id === lastUsed) || activeAccounts[0];
|
||||||
|
setSelectedChannel(defaultAccount.platform_user_id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TokenExpiredError) {
|
if (error instanceof TokenExpiredError) {
|
||||||
|
|
@ -335,6 +337,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
|
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('lastUsedSocialChannel', selectedAcc.platform_user_id);
|
||||||
|
|
||||||
if (publishTime === 'schedule') {
|
if (publishTime === 'schedule') {
|
||||||
// 예약 업로드: 폴링 없이 바로 완료 처리
|
// 예약 업로드: 폴링 없이 바로 완료 처리
|
||||||
setUploadStatus('completed');
|
setUploadStatus('completed');
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"myContents": "My Contents",
|
"myContents": "My Contents",
|
||||||
"myInfo": "My Info",
|
"myInfo": "My Info",
|
||||||
"defaultUser": "User",
|
"defaultUser": "User",
|
||||||
|
"credits": "Credits left: {{count}} / 3",
|
||||||
"loggingOut": "Logging out...",
|
"loggingOut": "Logging out...",
|
||||||
"logout": "Log Out",
|
"logout": "Log Out",
|
||||||
"tutorialRestart": "Restart Tutorial",
|
"tutorialRestart": "Restart Tutorial",
|
||||||
|
|
@ -250,7 +251,10 @@
|
||||||
"musicGenerationFailed": "Music generation failed.",
|
"musicGenerationFailed": "Music generation failed.",
|
||||||
"multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.",
|
"multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.",
|
||||||
"musicGenerationError": "An error occurred during music generation.",
|
"musicGenerationError": "An error occurred during music generation.",
|
||||||
"songRegenerationError": "An error occurred during music regeneration."
|
"songRegenerationError": "An error occurred during music regeneration.",
|
||||||
|
"creditsRemaining": "{{count}} left",
|
||||||
|
"creditsExhausted": "Not enough credits.",
|
||||||
|
"chargeCredits": "Purchase credits"
|
||||||
},
|
},
|
||||||
"completion": {
|
"completion": {
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
|
|
@ -474,7 +478,11 @@
|
||||||
"generateContent": "Generate Content",
|
"generateContent": "Generate Content",
|
||||||
"pageDescBefore": " reveals ",
|
"pageDescBefore": " reveals ",
|
||||||
"pageDescAfter": "'s core strategy.",
|
"pageDescAfter": "'s core strategy.",
|
||||||
"loadingTitle": "Analyzing Brand"
|
"loadingTitle": "Analyzing Brand",
|
||||||
|
"loadingStep1": "Collecting brand information...",
|
||||||
|
"loadingStep2": "Analyzing collected data...",
|
||||||
|
"loadingStep3": "Deriving key strategies...",
|
||||||
|
"loadingStep4": "Organizing results..."
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
|
|
@ -490,13 +498,13 @@
|
||||||
"title": "Content Calendar",
|
"title": "Content Calendar",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"completed": "Completed",
|
"completed": "Done",
|
||||||
"scheduled": "Scheduled",
|
"scheduled": "Planned",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"completed": "Completed",
|
"completed": "Done",
|
||||||
"scheduled": "Scheduled",
|
"scheduled": "Planned",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
|
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"myContents": "내 콘텐츠",
|
"myContents": "내 콘텐츠",
|
||||||
"myInfo": "내 정보",
|
"myInfo": "내 정보",
|
||||||
"defaultUser": "사용자",
|
"defaultUser": "사용자",
|
||||||
|
"credits": "잔여 크레딧: {{count}} / 3",
|
||||||
"loggingOut": "로그아웃 중...",
|
"loggingOut": "로그아웃 중...",
|
||||||
"logout": "로그아웃",
|
"logout": "로그아웃",
|
||||||
"tutorialRestart": "튜토리얼 다시 보기",
|
"tutorialRestart": "튜토리얼 다시 보기",
|
||||||
|
|
@ -250,7 +251,10 @@
|
||||||
"musicGenerationFailed": "음악 생성에 실패했습니다.",
|
"musicGenerationFailed": "음악 생성에 실패했습니다.",
|
||||||
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
|
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
|
||||||
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
|
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
|
||||||
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다."
|
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다.",
|
||||||
|
"creditsRemaining": "잔여 {{count}}",
|
||||||
|
"creditsExhausted": "크레딧이 부족합니다.",
|
||||||
|
"chargeCredits": "크레딧 충전하기"
|
||||||
},
|
},
|
||||||
"completion": {
|
"completion": {
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
|
|
@ -474,7 +478,11 @@
|
||||||
"generateContent": "콘텐츠 생성",
|
"generateContent": "콘텐츠 생성",
|
||||||
"pageDescBefore": "을 통해 도출된 ",
|
"pageDescBefore": "을 통해 도출된 ",
|
||||||
"pageDescAfter": "의 핵심 전략입니다.",
|
"pageDescAfter": "의 핵심 전략입니다.",
|
||||||
"loadingTitle": "브랜드 분석 중"
|
"loadingTitle": "브랜드 분석 중",
|
||||||
|
"loadingStep1": "브랜드 정보를 수집하는 중...",
|
||||||
|
"loadingStep2": "수집한 데이터를 분석하는 중...",
|
||||||
|
"loadingStep3": "핵심 전략을 도출하는 중...",
|
||||||
|
"loadingStep4": "결과를 정리하는 중..."
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
|
|
@ -502,7 +510,7 @@
|
||||||
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
|
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
|
||||||
"days": ["일","월","화","수","목","금","토"],
|
"days": ["일","월","화","수","목","금","토"],
|
||||||
"yearMonth": "{{year}}년 {{month}}",
|
"yearMonth": "{{year}}년 {{month}}",
|
||||||
"monthDay": "{{month}}월 {{day}}",
|
"monthDay": "{{month}} {{day}}일",
|
||||||
"loading": "불러오는 중...",
|
"loading": "불러오는 중...",
|
||||||
"noResults": "최근 결과 없음",
|
"noResults": "최근 결과 없음",
|
||||||
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
|
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [isComplete]);
|
}, [isComplete]);
|
||||||
|
|
||||||
|
const getStepMessage = (p: number) => {
|
||||||
|
if (p < 30) return t('analysis.loadingStep1');
|
||||||
|
if (p < 50) return t('analysis.loadingStep2');
|
||||||
|
if (p < 80) return t('analysis.loadingStep3');
|
||||||
|
return t('analysis.loadingStep4');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="loading-container">
|
<div className="loading-container">
|
||||||
<div className="loading-content">
|
<div className="loading-content">
|
||||||
|
|
@ -67,6 +74,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
|
||||||
</div>
|
</div>
|
||||||
<span className="loading-progress-text">{Math.floor(progress)}%</span>
|
<span className="loading-progress-text">{Math.floor(progress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="loading-step-message">{getStepMessage(progress)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ interface CompletionContentProps {
|
||||||
songTaskId: string | null;
|
songTaskId: string | null;
|
||||||
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
|
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
|
||||||
onVideoProgressChange?: (progress: number) => void;
|
onVideoProgressChange?: (progress: number) => void;
|
||||||
|
onVideoComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
|
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
|
||||||
|
|
@ -32,7 +33,8 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
onBack,
|
onBack,
|
||||||
songTaskId,
|
songTaskId,
|
||||||
onVideoStatusChange,
|
onVideoStatusChange,
|
||||||
onVideoProgressChange
|
onVideoProgressChange,
|
||||||
|
onVideoComplete,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
|
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
|
||||||
|
|
@ -253,6 +255,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
setVideoStatus('complete');
|
setVideoStatus('complete');
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
|
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
|
||||||
|
onVideoComplete?.();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(t('completion.videoUrlMissing'));
|
throw new Error(t('completion.videoUrlMissing'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -618,7 +618,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
|
|
||||||
// showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백
|
// showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백
|
||||||
const useReal = !showMockData && !isEmptyState;
|
const useReal = !showMockData && !isEmptyState;
|
||||||
const hasRealContentMetrics = useReal && !!dashboardData?.contentMetrics?.slice(0, -1).some((m: ContentMetric) => m.value > 0);
|
const hasRealContentMetrics = useReal && !!dashboardData?.contentMetrics?.some((m: ContentMetric) => m.value > 0);
|
||||||
const contentMetrics = hasRealContentMetrics ? dashboardData!.contentMetrics : MOCK_CONTENT_METRICS;
|
const contentMetrics = hasRealContentMetrics ? dashboardData!.contentMetrics : MOCK_CONTENT_METRICS;
|
||||||
|
|
||||||
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음 5)실제 지표 없음
|
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음 5)실제 지표 없음
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Sidebar from '../../components/Sidebar';
|
import Sidebar from '../../components/Sidebar';
|
||||||
import AssetManagementContent from './AssetManagementContent';
|
import AssetManagementContent from './AssetManagementContent';
|
||||||
|
|
@ -14,7 +14,7 @@ import ContentCalendarContent from './ContentCalendarContent';
|
||||||
import LoadingSection from '../Analysis/LoadingSection';
|
import LoadingSection from '../Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||||
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||||
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, clearTokens } from '../../utils/api';
|
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
||||||
|
|
@ -117,14 +117,25 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
||||||
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
||||||
|
const [credits, setCredits] = useState<number | null>(null);
|
||||||
const tutorial = useTutorial();
|
const tutorial = useTutorial();
|
||||||
|
|
||||||
// 로그인 직후 사용자 정보 조회
|
const refreshCredits = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { credits } = await getUserCredits();
|
||||||
|
setCredits(credits);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh credits:', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 로그인 직후 사용자 정보 + 크레딧 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserInfo = async () => {
|
const fetchUserInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getUserMe();
|
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]);
|
||||||
setUserInfo(data);
|
setUserInfo(data);
|
||||||
|
setCredits(creditsData.credits);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch user info:', error);
|
console.error('Failed to fetch user info:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -467,6 +478,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
songTaskId={songTaskId}
|
songTaskId={songTaskId}
|
||||||
onVideoStatusChange={setVideoGenerationStatus}
|
onVideoStatusChange={setVideoGenerationStatus}
|
||||||
onVideoProgressChange={setVideoGenerationProgress}
|
onVideoProgressChange={setVideoGenerationProgress}
|
||||||
|
onVideoComplete={refreshCredits}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|
@ -572,7 +584,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="analysis-page-wrapper">
|
<div className="analysis-page-wrapper">
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} />
|
||||||
)}
|
)}
|
||||||
<main className="analysis-page-main">
|
<main className="analysis-page-main">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
|
@ -585,7 +597,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} />
|
||||||
)}
|
)}
|
||||||
{tutorialUI}
|
{tutorialUI}
|
||||||
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
mId,
|
mId,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
videoGenerationStatus = 'idle',
|
videoGenerationStatus = 'idle',
|
||||||
videoGenerationProgress = 0
|
videoGenerationProgress = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedType, setSelectedType] = useState('보컬');
|
const [selectedType, setSelectedType] = useState('보컬');
|
||||||
|
|
@ -533,12 +533,20 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (() => {
|
||||||
|
const isCreditsError = errorMessage.includes('credit');
|
||||||
|
return (
|
||||||
<div className="error-message-new">
|
<div className="error-message-new">
|
||||||
{errorMessage}
|
{isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage}
|
||||||
</div>
|
{isCreditsError && (
|
||||||
|
<a href="#" className="charge-credits-link">
|
||||||
|
{t('soundStudio.chargeCredits')}
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Lyrics */}
|
{/* Right Column - Lyrics */}
|
||||||
<div className="lyrics-column">
|
<div className="lyrics-column">
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,10 @@ export interface UserMeResponse {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserCreditsResponse {
|
||||||
|
credits: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 비디오 목록 아이템
|
// 비디오 목록 아이템
|
||||||
export interface VideoListItem {
|
export interface VideoListItem {
|
||||||
video_id: number;
|
video_id: number;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
TokenExpiredErrorResponse,
|
TokenExpiredErrorResponse,
|
||||||
YTAutoSeoRequest,
|
YTAutoSeoRequest,
|
||||||
YTAutoSeoResponse,
|
YTAutoSeoResponse,
|
||||||
|
UserCreditsResponse,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||||
|
|
@ -651,6 +652,19 @@ export async function getUserMe(): Promise<UserMeResponse> {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자 크레딧 조회
|
||||||
|
export async function getUserCredits(): Promise<UserCreditsResponse> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/user/auth/me/credits`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// 로그인 여부 확인
|
// 로그인 여부 확인
|
||||||
export function isLoggedIn(): boolean {
|
export function isLoggedIn(): boolean {
|
||||||
return !!getAccessToken();
|
return !!getAccessToken();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue