UI 수정
parent
3cbc48181b
commit
8be7b7adcf
20
index.css
20
index.css
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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.includes('YouTube') && (
|
{error.code === 'YOUTUBE_TOKEN_EXPIRED' && (
|
||||||
|
<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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue