Files
web-page-backend/docs/superpowers/plans/2026-05-01-music-youtube-tab-frontend.md
2026-05-01 14:40:04 +09:00

1762 lines
57 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Music YouTube Tab Frontend Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** MusicStudio 페이지에 🎯 YouTube 탭을 추가하고, 영상 제작 / 수익 추적 / 시장 트렌드 3개 서브탭을 구현한다.
**Architecture:** MusicStudio.jsx의 기존 탭 state에 `'youtube'`를 추가하고 YoutubeTab 컴포넌트를 조건부 렌더링한다. YoutubeTab은 서브탭 state를 갖고 VideoProjectsTab / RevenueTab / TrendsTab을 렌더링한다. Library 탭의 LibraryCard에 "YouTube 프로젝트" 버튼을 추가해 트랙을 pre-select한 채 YouTube 탭으로 이동할 수 있다.
**Tech Stack:** React 18, Vite, plain fetch API helper (apiGet/apiPost/apiPut/apiDelete), CSS (MusicStudio.css 확장)
---
## 파일 구조
| 파일 | 변경 |
|------|------|
| `src/api.js` | 비디오/수익/트렌드 API 함수 추가 (파일 끝에 append) |
| `src/pages/music/MusicStudio.jsx` | YouTube 탭 버튼, YoutubeTab 렌더링, LibraryCard 버튼, initialTrackId state |
| `src/pages/music/MusicStudio.css` | `.yt-*` CSS 클래스 추가 |
| `src/pages/music/components/YoutubeTab.jsx` | 신규 — 서브탭 shell |
| `src/pages/music/components/VideoProjectsTab.jsx` | 신규 — 영상 제작 서브탭 |
| `src/pages/music/components/RevenueTab.jsx` | 신규 — 수익 추적 서브탭 |
| `src/pages/music/components/TrendsTab.jsx` | 신규 — 시장 트렌드 서브탭 |
---
## Task 1: Feature 브랜치 생성 + API 함수 추가
**작업 위치:** `/Users/jaeohpark/development/web-page/`
**Files:**
- Modify: `src/api.js` (파일 끝에 append)
- [ ] **Step 1: Feature 브랜치 생성**
```bash
cd /Users/jaeohpark/development/web-page
git checkout -b feat/music-youtube-tab
```
- [ ] **Step 2: `src/api.js` 파일 끝에 YouTube/Revenue/Trends API 함수 추가**
현재 파일은 628행. 파일 끝(`triggerLottoCurate` 함수 닫는 브레이스 다음)에 아래를 추가한다.
```js
// ── Music Lab — Video Projects ────────────────────
export const createVideoProject = (data) => apiPost('/api/music/video-project', data);
export const getVideoProjects = () => apiGet('/api/music/video-projects');
export const renderVideoProject = (id) => apiPost(`/api/music/video-project/${id}/render`);
export const exportVideoProject = (id) => apiGet(`/api/music/video-project/${id}/export`);
export const deleteVideoProject = (id) => apiDelete(`/api/music/video-project/${id}`);
// ── Music Lab — Revenue ───────────────────────────
export const getRevenueDashboard = () => apiGet('/api/music/revenue/dashboard');
export const getRevenueRecords = () => apiGet('/api/music/revenue');
export const addRevenueRecord = (data) => apiPost('/api/music/revenue', data);
export const updateRevenueRecord = (id, data) => apiPut(`/api/music/revenue/${id}`, data);
export const deleteRevenueRecord = (id) => apiDelete(`/api/music/revenue/${id}`);
// ── Music Lab — Market Trends ─────────────────────
export const getLatestTrendReport = () => apiGet('/api/music/market/report/latest');
export const getTrendReports = () => apiGet('/api/music/market/report');
export const getMarketSuggestions = () => apiGet('/api/music/market/suggest');
export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {});
```
- [ ] **Step 3: 브라우저 확인 불필요 (함수만 추가, 타입 오류 없음). dev 서버 시작 확인.**
```bash
cd /Users/jaeohpark/development/web-page
npm run dev
```
Expected: 콘솔에 에러 없이 `http://localhost:5173` (또는 포트 출력) 기동.
- [ ] **Step 4: 커밋**
```bash
cd /Users/jaeohpark/development/web-page
git add src/api.js
git commit -m "feat(api): video-project / revenue / market-trends API 함수 추가"
```
---
## Task 2: YoutubeTab.jsx — 서브탭 shell
**작업 위치:** `/Users/jaeohpark/development/web-page/`
**Files:**
- Create: `src/pages/music/components/YoutubeTab.jsx`
- [ ] **Step 1: `YoutubeTab.jsx` 생성**
```jsx
// src/pages/music/components/YoutubeTab.jsx
import { useState, useEffect } from 'react';
import VideoProjectsTab from './VideoProjectsTab';
import RevenueTab from './RevenueTab';
import TrendsTab from './TrendsTab';
export default function YoutubeTab({ library, initialTrackId, onClearInitialTrack }) {
const [subtab, setSubtab] = useState('video');
// initialTrackId가 들어오면 video 서브탭으로 전환
useEffect(() => {
if (initialTrackId) setSubtab('video');
}, [initialTrackId]);
return (
<div className="yt-container">
<nav className="yt-subtabs">
<button
type="button"
className={`yt-subtab ${subtab === 'video' ? 'is-active' : ''}`}
onClick={() => setSubtab('video')}
>
🎬 영상 제작
</button>
<button
type="button"
className={`yt-subtab ${subtab === 'revenue' ? 'is-active' : ''}`}
onClick={() => setSubtab('revenue')}
>
💰 수익 추적
</button>
<button
type="button"
className={`yt-subtab ${subtab === 'trends' ? 'is-active' : ''}`}
onClick={() => setSubtab('trends')}
>
📊 시장 트렌드
</button>
</nav>
{subtab === 'video' && (
<VideoProjectsTab
library={library}
initialTrackId={initialTrackId}
onClearInitialTrack={onClearInitialTrack}
/>
)}
{subtab === 'revenue' && <RevenueTab />}
{subtab === 'trends' && <TrendsTab />}
</div>
);
}
```
- [ ] **Step 2: 커밋**
```bash
cd /Users/jaeohpark/development/web-page
git add src/pages/music/components/YoutubeTab.jsx
git commit -m "feat(youtube-tab): YoutubeTab 서브탭 shell 컴포넌트"
```
---
## Task 3: VideoProjectsTab.jsx — 영상 제작 서브탭
**작업 위치:** `/Users/jaeohpark/development/web-page/`
**Files:**
- Create: `src/pages/music/components/VideoProjectsTab.jsx`
> **참고:** `video-projects` API 응답 형식은 `{ projects: [...] }` 또는 배열 직접 반환일 수 있다. 양쪽 모두 처리한다.
- [ ] **Step 1: `VideoProjectsTab.jsx` 생성**
```jsx
// src/pages/music/components/VideoProjectsTab.jsx
import { useState, useEffect, useRef } from 'react';
import {
createVideoProject, getVideoProjects,
renderVideoProject, exportVideoProject, deleteVideoProject,
} from '../../../api';
const COUNTRY_OPTIONS = ['BR', 'US', 'ID', 'MX', 'KR'];
const COUNTRY_FLAGS = { BR: '🇧🇷', US: '🇺🇸', ID: '🇮🇩', MX: '🇲🇽', KR: '🇰🇷' };
export default function VideoProjectsTab({ library, initialTrackId, onClearInitialTrack }) {
const [projects, setProjects] = useState([]);
const [selectedTrackId, setSelectedTrackId] = useState(initialTrackId ?? '');
const [format, setFormat] = useState('visualizer');
const [countries, setCountries] = useState(['BR']);
const [creating, setCreating] = useState(false);
const [exportData, setExportData] = useState(null);
const [exportingId, setExportingId] = useState(null);
const pollRef = useRef(null);
// initialTrackId prop 반영
useEffect(() => {
if (initialTrackId) {
setSelectedTrackId(String(initialTrackId));
onClearInitialTrack?.();
}
}, [initialTrackId]);
const loadProjects = async () => {
try {
const data = await getVideoProjects();
setProjects(Array.isArray(data) ? data : data.projects ?? []);
} catch (e) {
console.error('getVideoProjects:', e);
}
};
useEffect(() => { loadProjects(); }, []);
// 렌더링 중인 프로젝트가 있으면 5초마다 폴링
useEffect(() => {
const hasRendering = projects.some(p => p.status === 'rendering');
if (hasRendering && !pollRef.current) {
pollRef.current = setInterval(loadProjects, 5000);
} else if (!hasRendering && pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return () => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
};
}, [projects]);
const toggleCountry = (c) => {
setCountries(prev =>
prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]
);
};
const handleCreate = async () => {
if (!selectedTrackId || countries.length === 0) return;
setCreating(true);
try {
await createVideoProject({
track_id: Number(selectedTrackId),
format,
target_countries: countries,
});
await loadProjects();
} catch (e) {
console.error('createVideoProject:', e);
} finally {
setCreating(false);
}
};
const handleRender = async (id) => {
try {
await renderVideoProject(id);
await loadProjects();
} catch (e) {
console.error('renderVideoProject:', e);
}
};
const handleExport = async (id) => {
setExportingId(id);
try {
const data = await exportVideoProject(id);
setExportData({ id, ...data });
} catch (e) {
console.error('exportVideoProject:', e);
} finally {
setExportingId(null);
}
};
const handleDelete = async (id) => {
if (!window.confirm('이 프로젝트를 삭제할까요?')) return;
try {
await deleteVideoProject(id);
setProjects(prev => prev.filter(p => p.id !== id));
if (exportData?.id === id) setExportData(null);
} catch (e) {
console.error('deleteVideoProject:', e);
}
};
return (
<div className="yt-content">
{/* ① 새 영상 만들기 */}
<div className="yt-card yt-card--create">
<h3 className="yt-card__title"> 영상 만들기</h3>
<div className="yt-row">
<select
className="yt-select"
value={selectedTrackId}
onChange={e => setSelectedTrackId(e.target.value)}
>
<option value="">📚 트랙 선택...</option>
{(library ?? []).map(t => (
<option key={t.id} value={String(t.id)}>{t.title}</option>
))}
</select>
<div className="yt-format-toggle">
{['visualizer', 'slideshow'].map(f => (
<button
key={f}
type="button"
className={`yt-format-btn ${format === f ? 'is-active' : ''}`}
onClick={() => setFormat(f)}
>
{f === 'visualizer' ? '비주얼라이저' : '슬라이드쇼'}
</button>
))}
</div>
</div>
<div className="yt-country-label">타겟 국가 (복수 선택)</div>
<div className="yt-country-chips">
{COUNTRY_OPTIONS.map(c => (
<button
key={c}
type="button"
className={`yt-chip ${countries.includes(c) ? 'is-active' : ''}`}
onClick={() => toggleCountry(c)}
>
{COUNTRY_FLAGS[c]} {c}
</button>
))}
</div>
<button
type="button"
className="ms-btn ms-btn--primary yt-create-btn"
onClick={handleCreate}
disabled={creating || !selectedTrackId || countries.length === 0}
>
{creating ? '생성 중...' : '프로젝트 생성'}
</button>
</div>
{/* ② 프로젝트 목록 */}
<div className="yt-card">
<h3 className="yt-card__title"> 영상 프로젝트</h3>
{projects.length === 0 ? (
<p className="yt-empty">트랙을 선택해 영상을 만들어보세요</p>
) : (
<div className="yt-project-list">
{projects.map(p => (
<ProjectCard
key={p.id}
project={p}
onRender={handleRender}
onExport={handleExport}
onDelete={handleDelete}
isExporting={exportingId === p.id}
/>
))}
</div>
)}
</div>
{/* ③ 내보내기 패키지 */}
{exportData && (
<div className="yt-card yt-card--export">
<h3 className="yt-card__title"> 내보내기 패키지</h3>
<div className="yt-export-links">
{exportData.mp4_url && (
<a href={exportData.mp4_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
📹 output.mp4 다운로드
</a>
)}
{exportData.thumbnail_url && (
<a href={exportData.thumbnail_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
🖼 thumbnail.jpg
</a>
)}
</div>
{exportData.metadata && (
<div className="yt-meta-preview">
<div className="yt-meta-preview__label">metadata.json 미리보기</div>
<pre className="yt-meta-preview__content">
{JSON.stringify(exportData.metadata, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function ProjectCard({ project, onRender, onExport, onDelete, isExporting }) {
const STATUS_MAP = {
pending: { text: '대기', cls: 'yt-status--pending' },
rendering: { text: '⚙ 처리중', cls: 'yt-status--rendering' },
done: { text: '✓ 완료', cls: 'yt-status--done' },
failed: { text: '실패', cls: 'yt-status--failed' },
};
const s = STATUS_MAP[project.status] ?? { text: project.status, cls: '' };
return (
<div className="yt-project-card">
<div className="yt-project-card__icon">
{project.status === 'rendering' ? '⚙️' : project.status === 'done' ? '🎬' : '🎵'}
</div>
<div className="yt-project-card__info">
<div className="yt-project-card__title">
{project.title ?? `프로젝트 #${project.id}`}
</div>
<div className="yt-project-card__meta">
{project.format} · {(project.target_countries ?? []).join(' ')}
</div>
{project.status === 'rendering' && (
<div className="yt-progress-bar">
<div className="yt-progress-bar__fill" />
</div>
)}
</div>
<span className={`yt-status ${s.cls}`}>{s.text}</span>
{project.status === 'pending' && (
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onRender(project.id)}
>
렌더
</button>
)}
{project.status === 'done' && (
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onExport(project.id)}
disabled={isExporting}
>
{isExporting ? '...' : '↓ 내보내기'}
</button>
)}
<button
type="button"
className="ms-btn--icon ms-btn--danger"
onClick={() => onDelete(project.id)}
aria-label="삭제"
>
</button>
</div>
);
}
```
- [ ] **Step 2: 커밋**
```bash
cd /Users/jaeohpark/development/web-page
git add src/pages/music/components/VideoProjectsTab.jsx
git commit -m "feat(youtube-tab): VideoProjectsTab 영상 제작 서브탭"
```
---
## Task 4: RevenueTab.jsx — 수익 추적 서브탭
**Files:**
- Create: `src/pages/music/components/RevenueTab.jsx`
- [ ] **Step 1: `RevenueTab.jsx` 생성**
```jsx
// src/pages/music/components/RevenueTab.jsx
import { useState, useEffect } from 'react';
import {
getRevenueDashboard, getRevenueRecords,
addRevenueRecord, updateRevenueRecord, deleteRevenueRecord,
} from '../../../api';
const COUNTRIES = ['BR', 'US', 'ID', 'MX', 'KR'];
const currentMonth = () => new Date().toISOString().slice(0, 7);
export default function RevenueTab() {
const [dashboard, setDashboard] = useState(null);
const [records, setRecords] = useState([]);
const [form, setForm] = useState({
yt_video_id: '', record_month: currentMonth(),
revenue_usd: '', views: '', country: 'BR',
});
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({});
const loadAll = async () => {
const [dash, recs] = await Promise.all([
getRevenueDashboard().catch(() => null),
getRevenueRecords().catch(() => []),
]);
setDashboard(dash);
setRecords(Array.isArray(recs) ? recs : recs.records ?? []);
};
useEffect(() => { loadAll(); }, []);
const handleAdd = async () => {
if (!form.yt_video_id || !form.revenue_usd || !form.views) return;
setSaving(true);
try {
await addRevenueRecord({
yt_video_id: form.yt_video_id,
record_month: form.record_month,
revenue_usd: parseFloat(form.revenue_usd),
views: parseInt(form.views, 10),
country: form.country,
});
setForm({ yt_video_id: '', record_month: currentMonth(), revenue_usd: '', views: '', country: 'BR' });
await loadAll();
} catch (e) {
console.error('addRevenueRecord:', e);
} finally {
setSaving(false);
}
};
const handleEditSave = async () => {
try {
await updateRevenueRecord(editingId, {
revenue_usd: parseFloat(editForm.revenue_usd),
views: parseInt(editForm.views, 10),
});
setEditingId(null);
await loadAll();
} catch (e) {
console.error('updateRevenueRecord:', e);
}
};
const handleDelete = async (id) => {
if (!window.confirm('이 기록을 삭제할까요?')) return;
try {
await deleteRevenueRecord(id);
await loadAll();
} catch (e) {
console.error('deleteRevenueRecord:', e);
}
};
// 영상별 RPM 상위 5개 (bar chart 용)
const chartData = records
.filter(r => r.views > 0)
.map(r => ({
label: r.yt_video_id,
rpm: (r.revenue_usd / r.views) * 1000,
}))
.sort((a, b) => b.rpm - a.rpm)
.slice(0, 5);
const maxRpm = chartData.length > 0 ? Math.max(...chartData.map(d => d.rpm)) : 1;
return (
<div className="yt-content">
{/* 대시보드 카드 3개 */}
<div className="yt-dash-cards">
<div className="yt-dash-card">
<div className="yt-dash-card__label"> 수익</div>
<div className="yt-dash-card__value yt-dash-card__value--green">
${dashboard?.total_revenue_usd?.toFixed(2) ?? '—'}
</div>
<div className="yt-dash-card__sub">누적</div>
</div>
<div className="yt-dash-card">
<div className="yt-dash-card__label"> 조회수</div>
<div className="yt-dash-card__value yt-dash-card__value--blue">
{dashboard?.total_views != null
? (dashboard.total_views >= 1000
? `${(dashboard.total_views / 1000).toFixed(1)}K`
: String(dashboard.total_views))
: '—'}
</div>
<div className="yt-dash-card__sub">누적</div>
</div>
<div className="yt-dash-card">
<div className="yt-dash-card__label">평균 RPM</div>
<div className="yt-dash-card__value yt-dash-card__value--amber">
${dashboard?.avg_rpm?.toFixed(2) ?? '—'}
</div>
<div className="yt-dash-card__sub">가중평균</div>
</div>
</div>
{/* 영상별 RPM 바 차트 */}
{chartData.length > 0 && (
<div className="yt-card">
<h3 className="yt-card__title">영상별 RPM 비교</h3>
<div className="yt-bar-chart">
{chartData.map((d, i) => (
<div key={i} className="yt-bar-row">
<div className="yt-bar-row__label" title={d.label}>
{d.label.slice(0, 11)}
</div>
<div className="yt-bar-row__track">
<div
className="yt-bar-row__fill"
style={{ width: `${(d.rpm / maxRpm) * 100}%` }}
/>
</div>
<div className="yt-bar-row__value">${d.rpm.toFixed(2)}</div>
</div>
))}
</div>
</div>
)}
{/* 수익 기록 추가 폼 */}
<div className="yt-card yt-card--create">
<h3 className="yt-card__title">+ 수익 기록 추가</h3>
<div className="yt-form-grid">
<div className="yt-field">
<label className="yt-field__label">YouTube 영상 ID</label>
<input
className="yt-input"
value={form.yt_video_id}
onChange={e => setForm(f => ({ ...f, yt_video_id: e.target.value }))}
placeholder="dQw4w9WgXcQ"
/>
</div>
<div className="yt-field">
<label className="yt-field__label">기록 </label>
<input
className="yt-input"
type="month"
value={form.record_month}
onChange={e => setForm(f => ({ ...f, record_month: e.target.value }))}
/>
</div>
<div className="yt-field">
<label className="yt-field__label">수익 (USD)</label>
<input
className="yt-input"
type="number"
step="0.01"
value={form.revenue_usd}
onChange={e => setForm(f => ({ ...f, revenue_usd: e.target.value }))}
placeholder="3.45"
/>
</div>
<div className="yt-field">
<label className="yt-field__label">조회수</label>
<input
className="yt-input"
type="number"
value={form.views}
onChange={e => setForm(f => ({ ...f, views: e.target.value }))}
placeholder="1200"
/>
</div>
</div>
<div className="yt-row yt-row--bottom">
<select
className="yt-select"
value={form.country}
onChange={e => setForm(f => ({ ...f, country: e.target.value }))}
>
{COUNTRIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<button
type="button"
className="ms-btn ms-btn--primary"
onClick={handleAdd}
disabled={saving}
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
{/* 수익 기록 테이블 */}
<div className="yt-card">
<h3 className="yt-card__title">수익 기록</h3>
{records.length === 0 ? (
<p className="yt-empty">수익 기록이 없습니다. 폼으로 추가해보세요.</p>
) : (
<div className="yt-table">
<div className="yt-table__header">
<span>영상 ID</span>
<span></span>
<span>수익</span>
<span>조회수</span>
<span>RPM</span>
<span />
</div>
{records.map(rec => (
editingId === rec.id ? (
<div key={rec.id} className="yt-table__row yt-table__row--editing">
<span className="yt-table__cell">{rec.yt_video_id.slice(0, 11)}</span>
<span className="yt-table__cell">{rec.record_month}</span>
<input
className="yt-input yt-input--sm"
type="number"
step="0.01"
value={editForm.revenue_usd}
onChange={e => setEditForm(f => ({ ...f, revenue_usd: e.target.value }))}
/>
<input
className="yt-input yt-input--sm"
type="number"
value={editForm.views}
onChange={e => setEditForm(f => ({ ...f, views: e.target.value }))}
/>
<span className="yt-table__cell"></span>
<div className="yt-table__actions">
<button type="button" className="ms-btn ms-btn--primary ms-btn--sm" onClick={handleEditSave}>저장</button>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={() => setEditingId(null)}>취소</button>
</div>
</div>
) : (
<div
key={rec.id}
className="yt-table__row"
onClick={() => {
setEditingId(rec.id);
setEditForm({ revenue_usd: rec.revenue_usd, views: rec.views });
}}
style={{ cursor: 'pointer' }}
>
<span className="yt-table__cell yt-table__cell--mono">{rec.yt_video_id.slice(0, 11)}</span>
<span className="yt-table__cell">{rec.record_month}</span>
<span className="yt-table__cell yt-table__cell--green">${rec.revenue_usd?.toFixed(2)}</span>
<span className="yt-table__cell">{rec.views?.toLocaleString()}</span>
<span className="yt-table__cell yt-table__cell--amber">
{rec.views > 0
? `$${((rec.revenue_usd / rec.views) * 1000).toFixed(2)}`
: '—'}
</span>
<button
type="button"
className="ms-btn--icon ms-btn--danger"
onClick={e => { e.stopPropagation(); handleDelete(rec.id); }}
aria-label="삭제"
></button>
</div>
)
))}
</div>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: 커밋**
```bash
cd /Users/jaeohpark/development/web-page
git add src/pages/music/components/RevenueTab.jsx
git commit -m "feat(youtube-tab): RevenueTab 수익 추적 서브탭"
```
---
## Task 5: TrendsTab.jsx — 시장 트렌드 서브탭
**Files:**
- Create: `src/pages/music/components/TrendsTab.jsx`
- [ ] **Step 1: `TrendsTab.jsx` 생성**
```jsx
// src/pages/music/components/TrendsTab.jsx
import { useState, useEffect } from 'react';
import {
getLatestTrendReport, getTrendReports,
getMarketSuggestions, triggerYoutubeResearch,
} from '../../../api';
const FLAG = { BR: '🇧🇷', US: '🇺🇸', ID: '🇮🇩', MX: '🇲🇽', KR: '🇰🇷' };
export default function TrendsTab() {
const [latestReport, setLatestReport] = useState(null);
const [reports, setReports] = useState([]);
const [suggestions, setSuggestions] = useState([]);
const [selectedReport, setSelectedReport] = useState(null);
const [researching, setResearching] = useState(false);
const [copiedIdx, setCopiedIdx] = useState(null);
const loadAll = async () => {
const [latest, rpts, sugg] = await Promise.all([
getLatestTrendReport().catch(() => null),
getTrendReports().catch(() => []),
getMarketSuggestions().catch(() => []),
]);
setLatestReport(latest);
setReports(Array.isArray(rpts) ? rpts : rpts.reports ?? []);
setSuggestions(Array.isArray(sugg) ? sugg : sugg.suggestions ?? []);
};
useEffect(() => { loadAll(); }, []);
const handleResearch = async () => {
setResearching(true);
try {
await triggerYoutubeResearch();
} catch (e) {
console.error('triggerYoutubeResearch:', e);
} finally {
setResearching(false);
}
};
const handleCopy = (text, idx) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedIdx(idx);
setTimeout(() => setCopiedIdx(null), 2000);
});
};
// 선택된 리포트가 있으면 그것, 없으면 최신 리포트의 장르 표시
const displayReport = selectedReport ?? latestReport;
const topGenres = displayReport?.top_genres?.slice(0, 5) ?? [];
const maxScore = topGenres.length > 0 ? Math.max(...topGenres.map(g => g.score)) : 1;
return (
<div className="yt-content">
{/* 수집 상태 바 */}
<div className="yt-status-bar">
<div className="yt-status-bar__left">
<span className="yt-status-dot" />
<span className="yt-status-bar__text">
마지막 수집: <strong>{latestReport?.report_date ?? '없음'}</strong>
{latestReport && ` · ${latestReport.top_genres?.length ?? 0}개 장르`}
</span>
</div>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={handleResearch}
disabled={researching}
>
{researching ? '수집 중...' : '↻ 수동 수집'}
</button>
</div>
{/* 인기 장르 Top 5 */}
<div className="yt-card">
<h3 className="yt-card__title">🔥 오늘의 인기 장르 Top 5</h3>
{topGenres.length === 0 ? (
<p className="yt-empty">
트렌드 데이터가 없습니다. 수동 수집을 실행하거나 agent-office가 내일 09:00 자동 수집합니다.
</p>
) : (
<div className="yt-bar-chart yt-bar-chart--genre">
{topGenres.map((g, i) => (
<div key={i} className="yt-bar-row">
<div className="yt-bar-row__rank">#{i + 1}</div>
<div className="yt-bar-row__info">
<div className="yt-bar-row__genre-header">
<span className="yt-bar-row__genre-name">{g.genre}</span>
<span className="yt-bar-row__flags">
{(g.countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
</span>
</div>
<div className="yt-bar-row__track">
<div
className="yt-bar-row__fill yt-bar-row__fill--genre"
style={{ width: `${(g.score / maxScore) * 100}%` }}
/>
</div>
</div>
<div className="yt-bar-row__value">{g.score}</div>
</div>
))}
</div>
)}
</div>
{/* Suno 프롬프트 추천 */}
{suggestions.length > 0 && (
<div className="yt-card">
<h3 className="yt-card__title"> AI 추천 Suno 프롬프트</h3>
<div className="yt-prompt-list">
{suggestions.map((s, i) => (
<div key={i} className="yt-prompt-card">
<div className="yt-prompt-card__header">
<span className="yt-prompt-card__genre">{s.genre}</span>
<span className="yt-prompt-card__countries">
{(s.target_countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
</span>
</div>
<button
type="button"
className="yt-prompt-card__text"
onClick={() => handleCopy(s.suno_prompt, i)}
title="클릭해서 복사"
>
{s.suno_prompt}
</button>
{copiedIdx === i && (
<span className="yt-prompt-card__copied"> 복사됨</span>
)}
{s.reason && (
<div className="yt-prompt-card__reason">{s.reason}</div>
)}
</div>
))}
</div>
</div>
)}
{/* 트렌드 리포트 이력 */}
<div className="yt-card">
<h3 className="yt-card__title">📋 트렌드 리포트 이력</h3>
{reports.length === 0 ? (
<p className="yt-empty">리포트 이력이 없습니다</p>
) : (
<div className="yt-report-list">
{reports.map(r => (
<div
key={r.id ?? r.report_date}
className={`yt-report-row ${selectedReport?.report_date === r.report_date ? 'is-selected' : ''}`}
onClick={() => setSelectedReport(
selectedReport?.report_date === r.report_date ? null : r
)}
>
<span className="yt-report-row__date">
{r.report_date}
{r.report_date === latestReport?.report_date && (
<span className="yt-report-row__today"> 오늘</span>
)}
</span>
<span className="yt-report-row__meta">
{r.top_genres?.length ?? 0} 장르 · {r.recommended_styles?.length ?? 0} 추천
</span>
<span className="yt-report-row__action">보기 </span>
</div>
))}
</div>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: 커밋**
```bash
cd /Users/jaeohpark/development/web-page
git add src/pages/music/components/TrendsTab.jsx
git commit -m "feat(youtube-tab): TrendsTab 시장 트렌드 서브탭"
```
---
## Task 6: MusicStudio.jsx 연결 + CSS + Library 버튼
**Files:**
- Modify: `src/pages/music/MusicStudio.jsx`
- Modify: `src/pages/music/MusicStudio.css`
### 6-A: MusicStudio.jsx import 추가
- [ ] **Step 1: 파일 상단 import 블록에 YoutubeTab import 추가**
`MusicStudio.jsx` 파일 상단에서 기존 import 블록을 찾는다 (RemixTab import 근처). 그 아래에 추가:
```jsx
import YoutubeTab from './components/YoutubeTab';
```
기존 import 블록 예시 (3~10행 근처):
```jsx
import LyricsTab from './components/LyricsTab';
import RemixTab from './components/RemixTab';
// 이 아래에 추가:
import YoutubeTab from './components/YoutubeTab';
```
### 6-B: MusicStudio 함수 내 상태 추가
- [ ] **Step 2: `tab` state 선언 아래에 `initialTrackId` state 추가**
현재 517행:
```jsx
const [tab, setTab] = useState('create');
```
이 바로 아래에 추가:
```jsx
const [initialTrackId, setInitialTrackId] = useState(null);
```
### 6-C: LibraryCard에 YouTube 프로젝트 버튼 추가
- [ ] **Step 3: `LibraryCard` 컴포넌트 props에 `onVideoProject` 추가**
현재 340행:
```jsx
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating }) => {
```
이를 아래로 교체:
```jsx
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating }) => {
```
- [ ] **Step 4: `hasSunoId` 조건 블록의 `•••` 드롭다운 안에 YouTube 프로젝트 버튼 추가**
현재 426~427행 (`🎬 Music Video` 버튼 다음):
```jsx
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
disabled={isGenerating}>🎬 Music Video</button>
```
이 버튼 **아래**에 추가:
```jsx
<button type="button" onClick={() => { onVideoProject(track); setMenuOpen(false); }}>
🎯 YouTube 프로젝트
</button>
```
### 6-D: Library 컴포넌트에 onVideoProject prop 전달
- [ ] **Step 5: `Library` 컴포넌트 props에 `onVideoProject` 추가**
현재 450행:
```jsx
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => {
```
이를 아래로 교체:
```jsx
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating, loading }) => {
```
- [ ] **Step 6: `Library` 내부의 `LibraryCard` 렌더링에 `onVideoProject` prop 추가**
현재 491~515행의 `<LibraryCard>` 렌더링 블록에서, 기존 `onVideoGenerate={onVideoGenerate}` 아래에:
```jsx
onVideoProject={onVideoProject}
```
추가한다.
### 6-E: MusicStudio 함수 내 핸들러 추가
- [ ] **Step 7: `handleVideoGenerate` 핸들러 근처에 `handleVideoProject` 핸들러 추가**
`handleVideoGenerate` 함수를 찾아 (파일 내 `onVideoGenerate` 콜백 정의 위치) 그 바로 아래에 추가:
```jsx
const handleVideoProject = (track) => {
setInitialTrackId(track.id);
setTab('youtube');
};
```
### 6-F: Library 렌더링 블록에 prop 연결
- [ ] **Step 8: `tab === 'library'` 블록의 `<Library>` 컴포넌트에 `onVideoProject` prop 추가**
현재 1129~1143행의 `<Library>` 컴포넌트에서, 기존 `onVideoGenerate={handleVideoGenerate}` 아래에:
```jsx
onVideoProject={handleVideoProject}
```
추가한다.
### 6-G: YouTube 탭 버튼 추가
- [ ] **Step 9: 탭 nav에 YouTube 탭 버튼 추가**
현재 1117~1123행 (Remix 탭 버튼):
```jsx
<button
type="button"
className={`ms-tab ${tab === 'remix' ? 'is-active' : ''}`}
onClick={() => setTab('remix')}
>
<span className="ms-tab__icon">🔄</span> Remix
</button>
```
이 버튼 **다음**에 추가:
```jsx
<button
type="button"
className={`ms-tab ms-tab--youtube ${tab === 'youtube' ? 'is-active' : ''}`}
onClick={() => setTab('youtube')}
>
<span className="ms-tab__icon">🎯</span> YouTube
</button>
```
### 6-H: YouTube 탭 콘텐츠 렌더링 추가
- [ ] **Step 10: Remix 탭 렌더 블록 다음에 YouTube 탭 렌더 블록 추가**
현재 1151~1167행 (Remix 탭 렌더):
```jsx
{/* ═══ REMIX TAB ═══ */}
{tab === 'remix' && (
<RemixTab ... />
)}
```
이 블록 **다음**에 추가:
```jsx
{/* ═══ YOUTUBE TAB ═══ */}
{tab === 'youtube' && (
<YoutubeTab
library={library}
initialTrackId={initialTrackId}
onClearInitialTrack={() => setInitialTrackId(null)}
/>
)}
```
### 6-I: CSS 추가
- [ ] **Step 11: `MusicStudio.css` 파일 끝에 YouTube 탭 스타일 추가**
```css
/* ══════════════════════════════════════════
YouTube Tab — yt-* classes
══════════════════════════════════════════ */
/* YouTube 탭 버튼 강조 (amber) */
.ms-tab--youtube.is-active {
color: #f59e0b;
border-bottom-color: #f59e0b;
}
.yt-container {
display: flex;
flex-direction: column;
gap: 0;
}
/* ── 서브탭 네비게이션 ── */
.yt-subtabs {
display: flex;
border-bottom: 1px solid #1f2937;
background: #0d1117;
padding: 0 16px;
}
.yt-subtab {
padding: 10px 18px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.yt-subtab:hover { color: #9ca3af; }
.yt-subtab.is-active {
color: #22c55e;
border-bottom-color: #22c55e;
font-weight: 600;
}
/* ── 공통 콘텐츠 래퍼 ── */
.yt-content {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
/* ── 카드 ── */
.yt-card {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 14px;
}
.yt-card--create {
border-color: #22c55e33;
}
.yt-card--export {
border-color: #3b82f633;
border-style: dashed;
}
.yt-card__title {
font-size: 12px;
font-weight: 700;
color: #ccc;
margin: 0 0 12px;
}
.yt-card--create .yt-card__title { color: #86efac; }
.yt-card--export .yt-card__title { color: #93c5fd; }
/* ── 행 레이아웃 ── */
.yt-row {
display: flex;
gap: 8px;
margin-bottom: 10px;
align-items: center;
}
.yt-row--bottom {
margin-bottom: 0;
margin-top: 8px;
}
/* ── 폼 그리드 ── */
.yt-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 0;
}
.yt-field { display: flex; flex-direction: column; gap: 4px; }
.yt-field__label {
font-size: 10px;
color: #6b7280;
}
.yt-input {
background: #1f2937;
border: 1px solid #374151;
border-radius: 6px;
padding: 7px 10px;
color: #ccc;
font-size: 12px;
width: 100%;
box-sizing: border-box;
}
.yt-input:focus {
outline: none;
border-color: #22c55e;
}
.yt-input--sm {
padding: 4px 8px;
font-size: 11px;
}
/* ── 셀렉트 ── */
.yt-select {
flex: 1;
background: #1f2937;
border: 1px solid #374151;
border-radius: 6px;
padding: 8px 10px;
color: #9ca3af;
font-size: 12px;
}
/* ── 형식 토글 ── */
.yt-format-toggle {
display: flex;
gap: 4px;
}
.yt-format-btn {
background: #1f2937;
border: 1px solid #374151;
border-radius: 6px;
padding: 8px 10px;
color: #9ca3af;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
}
.yt-format-btn.is-active {
background: #1a2e1a;
border-color: #22c55e;
color: #86efac;
}
/* ── 국가 칩 ── */
.yt-country-label {
font-size: 11px;
color: #6b7280;
margin-bottom: 6px;
}
.yt-country-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.yt-chip {
background: #1f2937;
border: 1px solid #374151;
border-radius: 4px;
padding: 3px 10px;
color: #6b7280;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.yt-chip.is-active {
background: #1e3a2a;
border-color: #22c55e;
color: #86efac;
}
/* ── 생성 버튼 ── */
.yt-create-btn {
width: 100%;
margin-top: 2px;
}
/* ── 프로젝트 목록 ── */
.yt-project-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.yt-project-card {
background: #1f2937;
border-radius: 8px;
padding: 10px 12px;
display: flex;
align-items: center;
gap: 10px;
}
.yt-project-card__icon {
width: 40px;
height: 40px;
background: #111827;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.yt-project-card__info { flex: 1; min-width: 0; }
.yt-project-card__title {
font-size: 12px;
font-weight: 600;
color: #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yt-project-card__meta {
font-size: 10px;
color: #6b7280;
margin-top: 2px;
}
/* ── 상태 배지 ── */
.yt-status {
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.yt-status--pending { background: #1f2937; color: #9ca3af; }
.yt-status--rendering { background: #1a1500; color: #f59e0b; }
.yt-status--done { background: #0a3d1a; color: #22c55e; }
.yt-status--failed { background: #2d0a0a; color: #f87171; }
/* ── 진행 바 ── */
.yt-progress-bar {
height: 3px;
background: #374151;
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.yt-progress-bar__fill {
height: 100%;
width: 65%;
background: linear-gradient(90deg, #f59e0b, #fbbf24);
border-radius: 2px;
animation: yt-progress-pulse 2s ease-in-out infinite;
}
@keyframes yt-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* ── 내보내기 ── */
.yt-export-links {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.yt-meta-preview {
background: #111827;
border-radius: 6px;
padding: 8px;
}
.yt-meta-preview__label {
font-size: 10px;
color: #6b7280;
margin-bottom: 4px;
}
.yt-meta-preview__content {
font-size: 11px;
color: #9ca3af;
font-family: monospace;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
/* ── 빈 상태 ── */
.yt-empty {
text-align: center;
color: #6b7280;
font-size: 11px;
padding: 8px 0;
margin: 0;
}
/* ── 대시보드 카드 ── */
.yt-dash-cards {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
.yt-dash-card {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 8px;
padding: 12px;
text-align: center;
}
.yt-dash-card__label { font-size: 10px; color: #6b7280; margin-bottom: 4px; }
.yt-dash-card__sub { font-size: 9px; color: #6b7280; margin-top: 2px; }
.yt-dash-card__value { font-size: 18px; font-weight: 700; }
.yt-dash-card__value--green { color: #22c55e; }
.yt-dash-card__value--blue { color: #60a5fa; }
.yt-dash-card__value--amber { color: #f59e0b; }
/* ── 바 차트 ── */
.yt-bar-chart {
display: flex;
flex-direction: column;
gap: 8px;
}
.yt-bar-row {
display: flex;
align-items: center;
gap: 8px;
}
.yt-bar-row__label {
width: 80px;
font-size: 11px;
color: #9ca3af;
text-align: right;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.yt-bar-row__rank {
width: 24px;
font-size: 11px;
font-weight: 700;
color: #f59e0b;
text-align: center;
flex-shrink: 0;
}
.yt-bar-row__info { flex: 1; }
.yt-bar-row__genre-header {
display: flex;
justify-content: space-between;
margin-bottom: 3px;
}
.yt-bar-row__genre-name { font-size: 12px; color: #ccc; }
.yt-bar-row__flags { font-size: 10px; color: #9ca3af; }
.yt-bar-row__track {
flex: 1;
height: 6px;
background: #1f2937;
border-radius: 3px;
overflow: hidden;
}
.yt-bar-row__fill {
height: 100%;
background: linear-gradient(90deg, #22c55e, #4ade80);
border-radius: 3px;
transition: width 0.4s ease;
}
.yt-bar-row__fill--genre {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.yt-bar-row__value {
width: 44px;
font-size: 11px;
color: #22c55e;
text-align: right;
flex-shrink: 0;
}
/* ── 테이블 ── */
.yt-table { display: flex; flex-direction: column; gap: 2px; }
.yt-table__header {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 28px;
gap: 4px;
padding: 0 4px 6px;
border-bottom: 1px solid #1f2937;
font-size: 10px;
color: #6b7280;
}
.yt-table__row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 28px;
gap: 4px;
padding: 7px 4px;
border-bottom: 1px solid #111827;
align-items: center;
}
.yt-table__row--editing {
background: #111827;
border-radius: 6px;
padding: 8px;
}
.yt-table__row:last-child { border-bottom: none; }
.yt-table__cell { font-size: 11px; color: #9ca3af; }
.yt-table__cell--mono { font-family: monospace; }
.yt-table__cell--green { color: #22c55e; }
.yt-table__cell--amber { color: #f59e0b; }
.yt-table__actions {
display: flex;
gap: 4px;
grid-column: span 2;
}
/* ── 상태 바 (트렌드) ── */
.yt-status-bar {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 8px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.yt-status-bar__left {
display: flex;
align-items: center;
gap: 8px;
}
.yt-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 6px #22c55e;
flex-shrink: 0;
}
.yt-status-bar__text {
font-size: 11px;
color: #9ca3af;
}
/* ── 프롬프트 카드 ── */
.yt-prompt-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.yt-prompt-card {
background: #1a0d2e;
border-radius: 8px;
padding: 10px 12px;
}
.yt-prompt-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.yt-prompt-card__genre { font-size: 11px; font-weight: 700; color: #c084fc; }
.yt-prompt-card__countries { font-size: 10px; color: #6b7280; }
.yt-prompt-card__text {
display: block;
width: 100%;
text-align: left;
background: #110820;
border: none;
border-radius: 4px;
padding: 6px 8px;
font-size: 11px;
font-family: monospace;
color: #e9d5ff;
line-height: 1.6;
cursor: pointer;
transition: background 0.15s;
}
.yt-prompt-card__text:hover { background: #1a0d30; }
.yt-prompt-card__copied {
font-size: 10px;
color: #22c55e;
margin-top: 4px;
display: block;
}
.yt-prompt-card__reason {
font-size: 10px;
color: #6b7280;
margin-top: 5px;
}
/* ── 리포트 이력 ── */
.yt-report-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.yt-report-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.yt-report-row:hover { background: #1f2937; }
.yt-report-row.is-selected { background: #1f2937; }
.yt-report-row__date {
font-size: 11px;
color: #ccc;
}
.yt-report-row__today {
font-size: 10px;
color: #22c55e;
margin-left: 4px;
}
.yt-report-row__meta { font-size: 10px; color: #9ca3af; }
.yt-report-row__action { font-size: 11px; color: #60a5fa; }
/* ── 모바일 반응형 ── */
@media (max-width: 600px) {
.yt-dash-cards { grid-template-columns: 1fr 1fr; }
.yt-form-grid { grid-template-columns: 1fr; }
.yt-table__header,
.yt-table__row { grid-template-columns: 2fr 1fr 1fr 28px; }
.yt-table__header span:nth-child(4),
.yt-table__header span:nth-child(5),
.yt-table__row span:nth-child(4),
.yt-table__row span:nth-child(5) { display: none; }
}
```
- [ ] **Step 12: 커밋**
```bash
cd /Users/jaeohpark/development/web-page
git add src/pages/music/MusicStudio.jsx src/pages/music/MusicStudio.css
git commit -m "feat(youtube-tab): MusicStudio YouTube 탭 연결 + CSS + Library 버튼"
```
---
## Task 7: 브라우저 통합 검증
**작업 위치:** `/Users/jaeohpark/development/web-page/`
- [ ] **Step 1: dev 서버 시작 (이미 실행 중이면 스킵)**
```bash
cd /Users/jaeohpark/development/web-page
npm run dev
```
- [ ] **Step 2: 브라우저에서 `http://localhost:5173` (또는 출력된 포트) 열기**
- [ ] **Step 3: Music 페이지 → YouTube 탭 버튼 클릭 확인**
Expected:
- 탭 바에 `🎯 YouTube` 버튼이 보임
- 클릭 시 3개 서브탭(`🎬 영상 제작 / 💰 수익 추적 / 📊 시장 트렌드`) 표시
- 콘솔 에러 없음
- [ ] **Step 4: 영상 제작 서브탭 확인**
- 트랙 드롭다운에 Library 트랙 목록 표시 (Library에 트랙이 있는 경우)
- 국가 칩 BR/US/ID/MX/KR 클릭 토글 동작
- 비주얼라이저/슬라이드쇼 토글 동작
- "프로젝트 생성" 버튼 클릭 → 트랙 미선택 시 비활성화 확인
- [ ] **Step 5: 수익 추적 서브탭 확인**
- 대시보드 카드 3개 표시 (데이터 없으면 `—` 표시)
- 수익 추가 폼에 YouTube ID / 월 / 수익 / 조회수 / 국가 입력 후 저장
- 저장 후 테이블에 레코드 표시, 대시보드 수치 갱신
- [ ] **Step 6: 시장 트렌드 서브탭 확인**
- 수집 상태 바 표시 (마지막 수집 일시)
- 장르 Top 5 바 차트 표시 (데이터 있는 경우)
- Suno 프롬프트 클릭 → 클립보드 복사 + "✓ 복사됨" 메시지
- 리포트 이력 클릭 → 해당 날짜 데이터로 Top 5 갱신
- [ ] **Step 7: Library 탭 → 트랙 카드 `•••` → `🎯 YouTube 프로젝트` 버튼 확인**
Expected: 클릭 시 YouTube 탭으로 이동, 해당 트랙이 드롭다운에 pre-select됨
- [ ] **Step 8: 최종 커밋 및 PR 준비**
```bash
cd /Users/jaeohpark/development/web-page
git log --oneline feat/music-youtube-tab ^main
```
Expected: Task 1~6에서 만든 커밋 6개 표시.
```bash
git push -u origin feat/music-youtube-tab
```