UI 수정

feature-dashboard
김성경 2026-03-09 12:47:28 +09:00
parent 3cbc48181b
commit 8be7b7adcf
4 changed files with 65 additions and 66 deletions

View File

@ -2049,19 +2049,23 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 8px 20px 8px 8px; background-color: #462E64;
background: #462E64; color: #CFABFB;
font-size: 0.875rem;
font-weight: 600;
padding: 0 1.25rem 0 0.5rem;
height: 36px;
border: 1px solid #694596; border: 1px solid #694596;
border-radius: 999px; border-radius: 999px;
color: #CFABFB;
font-size: 14px;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: opacity 0.2s; white-space: nowrap;
transition: background-color 0.2s;
line-height: 1.19;
letter-spacing: -0.006em;
} }
.comp2-back-btn:hover { .comp2-back-btn:hover {
opacity: 0.85; background-color: #5a3a80;
} }
.comp2-container { .comp2-container {
@ -7857,7 +7861,7 @@
.lyrics-textarea { .lyrics-textarea {
width: 100%; width: 100%;
height: 200px; min-height: 200px;
background: transparent; background: transparent;
border: none; border: none;
color: var(--color-text-white); color: var(--color-text-white);

View File

@ -132,6 +132,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const [isPrivacyDropdownOpen, setIsPrivacyDropdownOpen] = useState(false); const [isPrivacyDropdownOpen, setIsPrivacyDropdownOpen] = useState(false);
const [isLoadingAutoDescription, setIsLoadingAutoDescription] = useState(false); const [isLoadingAutoDescription, setIsLoadingAutoDescription] = useState(false);
const [isHorizontalVideo, setIsHorizontalVideo] = useState(false); const [isHorizontalVideo, setIsHorizontalVideo] = useState(false);
const [videoSpecs, setVideoSpecs] = useState<{ resolution: string; duration: string } | null>(null);
const channelDropdownRef = useRef<HTMLDivElement>(null); const channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null); const privacyDropdownRef = useRef<HTMLDivElement>(null);
@ -436,6 +437,10 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onLoadedMetadata={(e) => { onLoadedMetadata={(e) => {
const v = e.currentTarget; const v = e.currentTarget;
setIsHorizontalVideo(v.videoWidth > v.videoHeight); setIsHorizontalVideo(v.videoWidth > v.videoHeight);
const totalSec = Math.floor(v.duration);
const m = Math.floor(totalSec / 60).toString().padStart(2, '0');
const s = (totalSec % 60).toString().padStart(2, '0');
setVideoSpecs({ resolution: `${v.videoWidth}×${v.videoHeight}`, duration: `${m}:${s}` });
}} }}
/> />
</div> </div>
@ -453,7 +458,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
<p className="social-posting-video-title"> <p className="social-posting-video-title">
{video.store_name} {new Date(video.created_at).toLocaleString('ko-KR')} {video.store_name} {new Date(video.created_at).toLocaleString('ko-KR')}
</p> </p>
<p className="social-posting-video-specs">{t('social.videoSpecs')}</p> {videoSpecs && (
<p className="social-posting-video-specs">{videoSpecs.resolution} · {videoSpecs.duration}</p>
)}
</div> </div>
{/* Channel Selector - Custom Dropdown */} {/* Channel Selector - Custom Dropdown */}

View File

@ -34,21 +34,6 @@ interface DailyData {
lastPeriod: number; lastPeriod: number;
} }
// interface PlatformMetric { // 미사용 — platform_data 기능 예정
// id: string;
// label: string;
// value: string;
// unit?: string;
// trend: number;
// trendDirection: 'up' | 'down';
// }
// interface PlatformData { // 미사용 — platform_data 기능 예정
// platform: 'youtube' | 'instagram';
// displayName: string;
// metrics: PlatformMetric[];
// }
interface TopContent { interface TopContent {
id: string; id: string;
title: string; title: string;
@ -65,6 +50,12 @@ interface AudienceData {
topRegions: { region: string; percentage: number }[]; topRegions: { region: string; percentage: number }[];
} }
interface DashboardError {
code: string;
message: string;
reconnect_url?: string;
}
interface DashboardResponse { interface DashboardResponse {
contentMetrics: ContentMetric[]; contentMetrics: ContentMetric[];
monthlyData: MonthlyData[]; monthlyData: MonthlyData[];
@ -72,7 +63,7 @@ interface DashboardResponse {
topContent: TopContent[]; topContent: TopContent[];
audienceData: AudienceData; audienceData: AudienceData;
hasUploads: boolean; // 업로드 영상 존재 여부 (false 시 mock 데이터 + 안내 메시지 표시) hasUploads: boolean; // 업로드 영상 존재 여부 (false 시 mock 데이터 + 안내 메시지 표시)
// platformData: PlatformData[]; // 미사용 error: DashboardError | null;
} }
interface ConnectedAccount { interface ConnectedAccount {
@ -487,7 +478,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
const [dashboardData, setDashboardData] = useState<DashboardResponse | null>(null); const [dashboardData, setDashboardData] = useState<DashboardResponse | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<DashboardError | null>(null);
const [showMockData, setShowMockData] = useState(false); const [showMockData, setShowMockData] = useState(false);
// 계정 관련 state // 계정 관련 state
@ -576,12 +567,6 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
if (errorData.code === 'YOUTUBE_NOT_CONNECTED') {
setError('YouTube 계정을 연동하여 데이터를 확인하세요.');
setDashboardData(null);
return;
}
if (errorData.code === 'YOUTUBE_ACCOUNT_SELECTION_REQUIRED') { if (errorData.code === 'YOUTUBE_ACCOUNT_SELECTION_REQUIRED') {
setDashboardData(null); setDashboardData(null);
return; return;
@ -590,6 +575,13 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
} }
const data: DashboardResponse = await response.json(); const data: DashboardResponse = await response.json();
if (data.error) {
setError(data.error);
setDashboardData(data);
return;
}
setDashboardData(data); setDashboardData(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
@ -612,11 +604,11 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
); );
} }
// hasUploads === false: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너 // hasUploads === false이고 error 없음: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너
const isEmptyState = dashboardData?.hasUploads === false; const isEmptyState = dashboardData?.hasUploads === false && !dashboardData?.error;
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 // 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음
const isBlurred = accounts.length === 0 || isEmptyState || !dashboardData; const isBlurred = accounts.length === 0 || isEmptyState || !dashboardData || !!error;
// API 데이터 우선 사용, 없거나 영상 없음(isEmptyState) 시 Mock 데이터로 폴백 // API 데이터 우선 사용, 없거나 영상 없음(isEmptyState) 시 Mock 데이터로 폴백
const contentMetrics = (!isEmptyState && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS; const contentMetrics = (!isEmptyState && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS;
@ -672,11 +664,17 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg> </svg>
<div> <p className="font-semibold">{error.message}</p>
<p className="font-semibold"> .</p>
</div> </div>
</div> {error.code === 'YOUTUBE_TOKEN_EXPIRED' && (
{error.includes('YouTube') && ( <button
onClick={() => onNavigate?.('내 정보')}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
>
</button>
)}
{error.code === 'YOUTUBE_NOT_CONNECTED' && (
<button <button
onClick={() => onNavigate?.('내 정보')} onClick={() => onNavigate?.('내 정보')}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap" className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"

View File

@ -518,32 +518,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</div> </div>
</div> </div>
{/* Generate Button */} {/* Generate Button / Status Message (교체) */}
<button {isGenerating && statusMessage ? (
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`btn-generate-sound ${isGenerating ? 'disabled' : ''}`}
>
{isGenerating ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{t('soundStudio.generating')}
</>
) : (
t('soundStudio.generateButton')
)}
</button>
{errorMessage && (
<div className="error-message-new">
{errorMessage}
</div>
)}
{isGenerating && statusMessage && (
<div className="status-message-new"> <div className="status-message-new">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"> <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
@ -551,6 +527,20 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</svg> </svg>
{statusMessage} {statusMessage}
</div> </div>
) : (
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`btn-generate-sound ${isGenerating ? 'disabled' : ''}`}
>
{t('soundStudio.generateButton')}
</button>
)}
{errorMessage && (
<div className="error-message-new">
{errorMessage}
</div>
)} )}
</div> </div>