From bdd52ed9925e8fcd8b006a1fc7d3e255e647a6aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 28 Apr 2026 14:32:55 +0900 Subject: [PATCH] =?UTF-8?q?=ED=81=AC=EB=A0=88=EB=94=A7=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.css | 55 ++++++++++++++++++++++ src/components/Sidebar.tsx | 6 ++- src/components/SocialPostingModal.tsx | 6 ++- src/locales/en.json | 20 +++++--- src/locales/ko.json | 14 ++++-- src/pages/Analysis/LoadingSection.tsx | 8 ++++ src/pages/Dashboard/CompletionContent.tsx | 5 +- src/pages/Dashboard/DashboardContent.tsx | 2 +- src/pages/Dashboard/GenerationFlow.tsx | 24 +++++++--- src/pages/Dashboard/SoundStudioContent.tsx | 20 +++++--- src/types/api.ts | 4 ++ src/utils/api.ts | 14 ++++++ 12 files changed, 153 insertions(+), 25 deletions(-) diff --git a/index.css b/index.css index 23927f6..e869518 100644 --- a/index.css +++ b/index.css @@ -780,6 +780,16 @@ 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 { padding: 0 1rem 0.75rem; @@ -7990,6 +8000,25 @@ resize: none; outline: none; 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 { @@ -8042,6 +8071,15 @@ text-align: center; } +.charge-credits-link { + display: block; + margin-top: 6px; + font-size: 13px; + color: #AE72F9; + text-decoration: underline; + cursor: pointer; +} + /* Video Generate Button */ .btn-video-generate { padding: 0.625rem 2.5rem; @@ -8450,6 +8488,23 @@ 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 */ .asset-load-more { width: 100%; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 08ea9ed..0a2ec5a 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -37,9 +37,10 @@ interface SidebarProps { onHome?: () => void; userInfo?: UserMeResponse | null; onLogout?: () => void; + credits?: number | null; } -const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userInfo, onLogout }) => { +const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits }) => { const { t } = useTranslation(); const [isCollapsed, setIsCollapsed] = useState(false); const [isMobileOpen, setIsMobileOpen] = useState(false); @@ -180,6 +181,9 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI {!isCollapsed && (

{userInfo?.nickname || t('sidebar.defaultUser')}

+ {credits !== null && credits !== undefined && ( +

{t('sidebar.credits', { count: credits })}

+ )}
)} diff --git a/src/components/SocialPostingModal.tsx b/src/components/SocialPostingModal.tsx index 08817cb..26c5f67 100644 --- a/src/components/SocialPostingModal.tsx +++ b/src/components/SocialPostingModal.tsx @@ -217,7 +217,9 @@ const SocialPostingModal: React.FC = ({ const activeAccounts = response.accounts?.filter(acc => acc.is_active) || []; setSocialAccounts(activeAccounts); 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) { if (error instanceof TokenExpiredError) { @@ -335,6 +337,8 @@ const SocialPostingModal: React.FC = ({ throw new Error(uploadResponse.message || t('social.uploadStartFailed')); } + localStorage.setItem('lastUsedSocialChannel', selectedAcc.platform_user_id); + if (publishTime === 'schedule') { // 예약 업로드: 폴링 없이 바로 완료 처리 setUploadStatus('completed'); diff --git a/src/locales/en.json b/src/locales/en.json index 35e726b..4e09f94 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -12,6 +12,7 @@ "myContents": "My Contents", "myInfo": "My Info", "defaultUser": "User", + "credits": "Credits left: {{count}} / 3", "loggingOut": "Logging out...", "logout": "Log Out", "tutorialRestart": "Restart Tutorial", @@ -250,7 +251,10 @@ "musicGenerationFailed": "Music generation failed.", "multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.", "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": { "back": "Go Back", @@ -474,7 +478,11 @@ "generateContent": "Generate Content", "pageDescBefore": " reveals ", "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": { "back": "Go Back", @@ -490,13 +498,13 @@ "title": "Content Calendar", "tabs": { "all": "All", - "completed": "Completed", - "scheduled": "Scheduled", + "completed": "Done", + "scheduled": "Planned", "failed": "Failed" }, "status": { - "completed": "Completed", - "scheduled": "Scheduled", + "completed": "Done", + "scheduled": "Planned", "failed": "Failed" }, "months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"], diff --git a/src/locales/ko.json b/src/locales/ko.json index 35b8cb4..81472b0 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -12,6 +12,7 @@ "myContents": "내 콘텐츠", "myInfo": "내 정보", "defaultUser": "사용자", + "credits": "잔여 크레딧: {{count}} / 3", "loggingOut": "로그아웃 중...", "logout": "로그아웃", "tutorialRestart": "튜토리얼 다시 보기", @@ -250,7 +251,10 @@ "musicGenerationFailed": "음악 생성에 실패했습니다.", "multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.", "musicGenerationError": "음악 생성 중 오류가 발생했습니다.", - "songRegenerationError": "음악 재생성 중 오류가 발생했습니다." + "songRegenerationError": "음악 재생성 중 오류가 발생했습니다.", + "creditsRemaining": "잔여 {{count}}", + "creditsExhausted": "크레딧이 부족합니다.", + "chargeCredits": "크레딧 충전하기" }, "completion": { "back": "뒤로가기", @@ -474,7 +478,11 @@ "generateContent": "콘텐츠 생성", "pageDescBefore": "을 통해 도출된 ", "pageDescAfter": "의 핵심 전략입니다.", - "loadingTitle": "브랜드 분석 중" + "loadingTitle": "브랜드 분석 중", + "loadingStep1": "브랜드 정보를 수집하는 중...", + "loadingStep2": "수집한 데이터를 분석하는 중...", + "loadingStep3": "핵심 전략을 도출하는 중...", + "loadingStep4": "결과를 정리하는 중..." }, "common": { "back": "뒤로가기", @@ -502,7 +510,7 @@ "months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"], "days": ["일","월","화","수","목","금","토"], "yearMonth": "{{year}}년 {{month}}", - "monthDay": "{{month}}월 {{day}}", + "monthDay": "{{month}} {{day}}일", "loading": "불러오는 중...", "noResults": "최근 결과 없음", "noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요", diff --git a/src/pages/Analysis/LoadingSection.tsx b/src/pages/Analysis/LoadingSection.tsx index a71722c..ab32e8d 100755 --- a/src/pages/Analysis/LoadingSection.tsx +++ b/src/pages/Analysis/LoadingSection.tsx @@ -38,6 +38,13 @@ const LoadingSection: React.FC = ({ onComplete, isComplete return () => clearTimeout(timer); }, [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 (
@@ -67,6 +74,7 @@ const LoadingSection: React.FC = ({ onComplete, isComplete
{Math.floor(progress)}%
+

{getStepMessage(progress)}

diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index ce1133d..5993fe9 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -12,6 +12,7 @@ interface CompletionContentProps { songTaskId: string | null; onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void; onVideoProgressChange?: (progress: number) => void; + onVideoComplete?: () => void; } type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error'; @@ -32,7 +33,8 @@ const CompletionContent: React.FC = ({ onBack, songTaskId, onVideoStatusChange, - onVideoProgressChange + onVideoProgressChange, + onVideoComplete, }) => { const { t } = useTranslation(); const [videoStatus, setVideoStatus] = useState('idle'); @@ -253,6 +255,7 @@ const CompletionContent: React.FC = ({ setVideoStatus('complete'); setStatusMessage(''); saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId); + onVideoComplete?.(); } else { throw new Error(t('completion.videoUrlMissing')); } diff --git a/src/pages/Dashboard/DashboardContent.tsx b/src/pages/Dashboard/DashboardContent.tsx index 37ed99e..b82c6d6 100755 --- a/src/pages/Dashboard/DashboardContent.tsx +++ b/src/pages/Dashboard/DashboardContent.tsx @@ -618,7 +618,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => { // showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백 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; // 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음 5)실제 지표 없음 diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 86103a2..ec60fdc 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -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 Sidebar from '../../components/Sidebar'; import AssetManagementContent from './AssetManagementContent'; @@ -14,7 +14,7 @@ import ContentCalendarContent from './ContentCalendarContent'; import LoadingSection from '../Analysis/LoadingSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection'; 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 { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay'; @@ -117,14 +117,25 @@ const GenerationFlow: React.FC = ({ const [analysisError, setAnalysisError] = useState(null); const [isAnalysisComplete, setIsAnalysisComplete] = useState(false); const [userInfo, setUserInfo] = useState(null); + const [credits, setCredits] = useState(null); const tutorial = useTutorial(); - // 로그인 직후 사용자 정보 조회 + const refreshCredits = useCallback(async () => { + try { + const { credits } = await getUserCredits(); + setCredits(credits); + } catch (e) { + console.error('Failed to refresh credits:', e); + } + }, []); + + // 로그인 직후 사용자 정보 + 크레딧 조회 useEffect(() => { const fetchUserInfo = async () => { try { - const data = await getUserMe(); + const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]); setUserInfo(data); + setCredits(creditsData.credits); } catch (error) { console.error('Failed to fetch user info:', error); } @@ -467,6 +478,7 @@ const GenerationFlow: React.FC = ({ songTaskId={songTaskId} onVideoStatusChange={setVideoGenerationStatus} onVideoProgressChange={setVideoGenerationProgress} + onVideoComplete={refreshCredits} /> ); default: @@ -572,7 +584,7 @@ const GenerationFlow: React.FC = ({ return (
{showSidebar && ( - + )}
{renderContent()} @@ -585,7 +597,7 @@ const GenerationFlow: React.FC = ({ return (
{showSidebar && ( - + )} {tutorialUI}
diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx index fd87a52..5fdcdce 100755 --- a/src/pages/Dashboard/SoundStudioContent.tsx +++ b/src/pages/Dashboard/SoundStudioContent.tsx @@ -42,7 +42,7 @@ const SoundStudioContent: React.FC = ({ mId, onStatusChange, videoGenerationStatus = 'idle', - videoGenerationProgress = 0 + videoGenerationProgress = 0, }) => { const { t } = useTranslation(); const [selectedType, setSelectedType] = useState('보컬'); @@ -533,11 +533,19 @@ const SoundStudioContent: React.FC = ({ )} - {errorMessage && ( -
- {errorMessage} -
- )} + {errorMessage && (() => { + const isCreditsError = errorMessage.includes('credit'); + return ( +
+ {isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage} + {isCreditsError && ( + + {t('soundStudio.chargeCredits')} + + )} +
+ ); + })()}
{/* Right Column - Lyrics */} diff --git a/src/types/api.ts b/src/types/api.ts index 3a3609e..c347607 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -254,6 +254,10 @@ export interface UserMeResponse { created_at: string; } +export interface UserCreditsResponse { + credits: number; +} + // 비디오 목록 아이템 export interface VideoListItem { video_id: number; diff --git a/src/utils/api.ts b/src/utils/api.ts index 2c980a4..877c375 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -28,6 +28,7 @@ import { TokenExpiredErrorResponse, YTAutoSeoRequest, YTAutoSeoResponse, + UserCreditsResponse, } from '../types/api'; export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; @@ -651,6 +652,19 @@ export async function getUserMe(): Promise { return response.json(); } +// 사용자 크레딧 조회 +export async function getUserCredits(): Promise { + 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 { return !!getAccessToken();