Compare commits

11 Commits

Author SHA1 Message Date
4281c1873f feat(music): Create 탭 트랙 제목 직접 입력 추가 2026-05-01 15:49:56 +09:00
8a7b5e8a38 fix(music): setTimeout 정리 + useCallback 폴링 deps
- TrendsTab: useRef로 타이머 ID 추적 후 언마운트 시 clearTimeout 호출 (stale setState 방지)
- VideoProjectsTab: loadProjects를 useCallback으로 감싸고 폴링 useEffect deps에 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:13:04 +09:00
08981a292a feat(youtube-tab): MusicStudio YouTube 탭 연결 + CSS + Library 버튼
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:08:15 +09:00
ed95f6678f fix(music): TrendsTab 리포트 이력 메타데이터 장르/추천수 표시
리포트 목록 행의 메타 정보를 insights 미리보기에서 장르/추천 개수로 교체.
이제 list 응답에 top_genres·recommended_styles가 포함되므로 클릭 시
장르 차트와 Suno 프롬프트가 정상 표시됨.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:03:34 +09:00
1847771ad2 fix(music): TrendsTab 로딩상태·에러피드백·메타데이터 수정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:01:38 +09:00
0f0ca8610d fix(music): TrendsTab 리포트 selected_styles 표시 + created_at 시간 포맷
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 14:56:47 +09:00
3f2fdb095c feat(youtube-tab): TrendsTab 시장 트렌드 서브탭 2026-05-01 14:51:10 +09:00
3e54b2c98d feat(youtube-tab): RevenueTab 수익 추적 서브탭 2026-05-01 14:48:47 +09:00
16b8cc59ae feat(youtube-tab): VideoProjectsTab 영상 제작 서브탭 2026-05-01 14:46:27 +09:00
a89de57b79 feat(youtube-tab): YoutubeTab 서브탭 shell 컴포넌트 + 스텁 탭 추가 2026-05-01 14:44:21 +09:00
413dccb655 feat(api): video-project / revenue / market-trends API 함수 추가 2026-05-01 14:42:15 +09:00
7 changed files with 1360 additions and 3 deletions

View File

@@ -626,3 +626,23 @@ export async function triggerLottoCurate() {
return r.json(); return r.json();
} }
// ── 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', {});

View File

@@ -932,6 +932,27 @@
margin: 0; margin: 0;
} }
/* ── Track title input ── */
.ms-title-input-wrap {
padding: 0 24px;
margin-bottom: 12px;
}
.ms-title-input {
width: 100%;
box-sizing: border-box;
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 9px 14px;
color: #ccc;
font-size: 13px;
text-align: center;
}
.ms-title-input::placeholder { color: #4b5563; }
.ms-title-input:focus { outline: none; border-color: var(--ms-accent, #22c55e); }
/* ═══════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════
GENERATE BUTTON GENERATE BUTTON
═══════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════ */
@@ -2608,3 +2629,459 @@
background: var(--ms-surface); border: 1px solid var(--ms-line); background: var(--ms-surface); border: 1px solid var(--ms-line);
} }
.ms-remix-submit { align-self: flex-start; margin-top: 8px; } .ms-remix-submit { align-self: flex-start; margin-top: 8px; }
/* ══════════════════════════════════════════
YouTube Tab — yt-* classes
══════════════════════════════════════════ */
.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; }
}

View File

@@ -27,6 +27,7 @@ import LyricsTab from './components/LyricsTab';
import StemModal from './components/StemModal'; import StemModal from './components/StemModal';
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer'; import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
import RemixTab from './components/RemixTab'; import RemixTab from './components/RemixTab';
import YoutubeTab from './components/YoutubeTab';
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
데이터 상수 데이터 상수
@@ -337,7 +338,7 @@ const TrackResult = ({ track, onDownload, onNew }) => {
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Library Card Library Card
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating }) => { const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating }) => {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const genre = GENRES.find((g) => g.id === track.genre); const genre = GENRES.find((g) => g.id === track.genre);
const totalSec = track.duration_sec ?? null; const totalSec = track.duration_sec ?? null;
@@ -425,6 +426,9 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button> disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }} <button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
disabled={isGenerating}>🎬 Music Video</button> disabled={isGenerating}>🎬 Music Video</button>
<button type="button" onClick={() => { onVideoProject(track); setMenuOpen(false); }}>
🎯 YouTube 프로젝트
</button>
</div> </div>
)} )}
</div> </div>
@@ -447,7 +451,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Library Section Library Section
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => { const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating, loading }) => {
const [playingId, setPlayingId] = useState(null); const [playingId, setPlayingId] = useState(null);
const handlePlay = (track) => { const handlePlay = (track) => {
@@ -501,6 +505,7 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
onStemSplit={onStemSplit} onStemSplit={onStemSplit}
onSyncedLyrics={onSyncedLyrics} onSyncedLyrics={onSyncedLyrics}
onVideoGenerate={onVideoGenerate} onVideoGenerate={onVideoGenerate}
onVideoProject={onVideoProject}
isGenerating={isGenerating} isGenerating={isGenerating}
/> />
))} ))}
@@ -515,6 +520,7 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
export default function MusicStudio() { export default function MusicStudio() {
/* ── 탭 ── */ /* ── 탭 ── */
const [tab, setTab] = useState('create'); const [tab, setTab] = useState('create');
const [initialTrackId, setInitialTrackId] = useState(null);
/* ── Provider 상태 ── */ /* ── Provider 상태 ── */
const [providers, setProviders] = useState([]); const [providers, setProviders] = useState([]);
@@ -530,6 +536,7 @@ export default function MusicStudio() {
const [musicalKey, setMusicalKey] = useState('C'); const [musicalKey, setMusicalKey] = useState('C');
const [scale, setScale] = useState('Major'); const [scale, setScale] = useState('Major');
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [customTitle, setCustomTitle] = useState('');
/* ── Suno 전용 상태 ── */ /* ── Suno 전용 상태 ── */
const [lyrics, setLyrics] = useState(''); const [lyrics, setLyrics] = useState('');
@@ -730,7 +737,7 @@ export default function MusicStudio() {
const durSec = DURATIONS.find((d) => d.id === duration)?.sec ?? 60; const durSec = DURATIONS.find((d) => d.id === duration)?.sec ?? 60;
const moodLabel = moods[0] ? MOODS.find((m) => m.id === moods[0])?.label : 'Original'; const moodLabel = moods[0] ? MOODS.find((m) => m.id === moods[0])?.label : 'Original';
const title = `${activeGenre?.label}${moodLabel} Mix`; const title = customTitle.trim() || `${activeGenre?.label}${moodLabel} Mix`;
const instList = instruments.length > 0 ? instruments : ['piano', 'synth', 'bass']; const instList = instruments.length > 0 ? instruments : ['piano', 'synth', 'bass'];
const payload = { const payload = {
@@ -1058,10 +1065,16 @@ export default function MusicStudio() {
} }
}; };
const handleVideoProject = (track) => {
setInitialTrackId(track.id);
setTab('youtube');
};
const handleNewTrack = () => { const handleNewTrack = () => {
setTrack(null); setTrack(null);
setGenProgress(0); setGenProgress(0);
setGenError(null); setGenError(null);
setCustomTitle('');
clearInterval(pollRef.current); clearInterval(pollRef.current);
}; };
@@ -1121,6 +1134,13 @@ export default function MusicStudio() {
> >
<span className="ms-tab__icon">🔄</span> Remix <span className="ms-tab__icon">🔄</span> Remix
</button> </button>
<button
type="button"
className={`ms-tab ms-tab--youtube ${tab === 'youtube' ? 'is-active' : ''}`}
onClick={() => setTab('youtube')}
>
<span className="ms-tab__icon">🎯</span> YouTube
</button>
</nav> </nav>
{/* ═══ LIBRARY TAB ═══ */} {/* ═══ LIBRARY TAB ═══ */}
@@ -1138,6 +1158,7 @@ export default function MusicStudio() {
onStemSplit={handleStemSplit} onStemSplit={handleStemSplit}
onSyncedLyrics={handleSyncedLyrics} onSyncedLyrics={handleSyncedLyrics}
onVideoGenerate={handleVideoGenerate} onVideoGenerate={handleVideoGenerate}
onVideoProject={handleVideoProject}
isGenerating={isGenerating} isGenerating={isGenerating}
/> />
</PullToRefresh> </PullToRefresh>
@@ -1166,6 +1187,15 @@ export default function MusicStudio() {
/> />
)} )}
{/* ═══ YOUTUBE TAB ═══ */}
{tab === 'youtube' && (
<YoutubeTab
library={library}
initialTrackId={initialTrackId}
onClearInitialTrack={() => setInitialTrackId(null)}
/>
)}
{/* ═══ CREATE TAB ═══ */} {/* ═══ CREATE TAB ═══ */}
{tab === 'create' && ( {tab === 'create' && (
<div className="ms-layout"> <div className="ms-layout">
@@ -1661,6 +1691,20 @@ export default function MusicStudio() {
</div> </div>
</div> </div>
{/* Track title input */}
{!track && (
<div className="ms-title-input-wrap">
<input
type="text"
className="ms-title-input"
placeholder="트랙 제목 (비워두면 자동 생성)"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
maxLength={80}
/>
</div>
)}
{/* Generate button */} {/* Generate button */}
{!track && ( {!track && (
<button <button

View File

@@ -0,0 +1,276 @@
// 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>
);
}

View File

@@ -0,0 +1,220 @@
// src/pages/music/components/TrendsTab.jsx
import { useState, useEffect, useRef } from 'react';
import {
getLatestTrendReport, getTrendReports,
getMarketSuggestions, triggerYoutubeResearch,
} from '../../../api';
const FLAG = { BR: '🇧🇷', US: '🇺🇸', ID: '🇮🇩', MX: '🇲🇽', KR: '🇰🇷' };
function fmtDateTime(iso) {
if (!iso) return null;
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(0, 10);
const today = new Date().toDateString();
if (d.toDateString() === today) {
return `오늘 ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
return iso.slice(0, 10); // YYYY-MM-DD
}
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 [loading, setLoading] = useState(true);
const [researchMsg, setResearchMsg] = useState('');
const researchTimerRef = useRef(null);
const copyTimerRef = useRef(null);
const loadAll = async () => {
setLoading(true);
try {
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 ?? []);
} catch (e) {
console.error('loadAll:', e);
} finally {
setLoading(false);
}
};
useEffect(() => { loadAll(); }, []);
useEffect(() => {
return () => {
clearTimeout(researchTimerRef.current);
clearTimeout(copyTimerRef.current);
};
}, []);
const handleResearch = async () => {
setResearching(true);
try {
await triggerYoutubeResearch();
setResearchMsg('수집이 시작되었습니다. 잠시 후 새로고침하세요.');
clearTimeout(researchTimerRef.current);
researchTimerRef.current = setTimeout(() => setResearchMsg(''), 4000);
} catch (e) {
console.error('triggerYoutubeResearch:', e);
} finally {
setResearching(false);
}
};
const handleCopy = (text, idx) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedIdx(idx);
clearTimeout(copyTimerRef.current);
copyTimerRef.current = 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;
// Suno 프롬프트: 선택된 리포트가 있으면 그것의 recommended_styles, 없으면 라이브 suggestions
const displaySuggestions = selectedReport
? (selectedReport.recommended_styles ?? [])
: suggestions;
if (loading) return <div className="yt-content"><p className="yt-empty">데이터 로딩 ...</p></div>;
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>{fmtDateTime(latestReport?.created_at) ?? 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>
{researchMsg && <p className="yt-empty" style={{ color: '#22c55e', marginTop: 4 }}>{researchMsg}</p>}
</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 프롬프트 추천 */}
{displaySuggestions.length > 0 && (
<div className="yt-card">
<h3 className="yt-card__title">
{selectedReport
? `${selectedReport.report_date} 추천 프롬프트`
: '✨ AI 추천 Suno 프롬프트'}
</h3>
<div className="yt-prompt-list">
{displaySuggestions.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);
setCopiedIdx(null);
}}
>
<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>
);
}

View File

@@ -0,0 +1,269 @@
// src/pages/music/components/VideoProjectsTab.jsx
import { useState, useEffect, useRef, useCallback } 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 = useCallback(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, loadProjects]);
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>
);
}

View File

@@ -0,0 +1,51 @@
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>
);
}