# 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 (
{subtab === 'video' && (
)}
{subtab === 'revenue' && }
{subtab === 'trends' && }
);
}
```
- [ ] **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 (
{/* โ ์ ์์ ๋ง๋ค๊ธฐ */}
โ ์ ์์ ๋ง๋ค๊ธฐ
{['visualizer', 'slideshow'].map(f => (
))}
ํ๊ฒ ๊ตญ๊ฐ (๋ณต์ ์ ํ)
{COUNTRY_OPTIONS.map(c => (
))}
{/* โก ํ๋ก์ ํธ ๋ชฉ๋ก */}
โก ์์ ํ๋ก์ ํธ
{projects.length === 0 ? (
ํธ๋์ ์ ํํด ์์์ ๋ง๋ค์ด๋ณด์ธ์
) : (
)}
{/* โข ๋ด๋ณด๋ด๊ธฐ ํจํค์ง */}
{exportData && (
โข ๋ด๋ณด๋ด๊ธฐ ํจํค์ง
{exportData.metadata && (
metadata.json ๋ฏธ๋ฆฌ๋ณด๊ธฐ
{JSON.stringify(exportData.metadata, null, 2)}
)}
)}
);
}
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 (
{project.status === 'rendering' ? 'โ๏ธ' : project.status === 'done' ? '๐ฌ' : '๐ต'}
{project.title ?? `ํ๋ก์ ํธ #${project.id}`}
{project.format} ยท {(project.target_countries ?? []).join(' ')}
{project.status === 'rendering' && (
)}
{s.text}
{project.status === 'pending' && (
)}
{project.status === 'done' && (
)}
);
}
```
- [ ] **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 (
{/* ๋์๋ณด๋ ์นด๋ 3๊ฐ */}
์ด ์์ต
${dashboard?.total_revenue_usd?.toFixed(2) ?? 'โ'}
๋์
์ด ์กฐํ์
{dashboard?.total_views != null
? (dashboard.total_views >= 1000
? `${(dashboard.total_views / 1000).toFixed(1)}K`
: String(dashboard.total_views))
: 'โ'}
๋์
ํ๊ท RPM
${dashboard?.avg_rpm?.toFixed(2) ?? 'โ'}
๊ฐ์คํ๊ท
{/* ์์๋ณ RPM ๋ฐ ์ฐจํธ */}
{chartData.length > 0 && (
์์๋ณ RPM ๋น๊ต
{chartData.map((d, i) => (
{d.label.slice(0, 11)}
${d.rpm.toFixed(2)}
))}
)}
{/* ์์ต ๊ธฐ๋ก ์ถ๊ฐ ํผ */}
+ ์์ต ๊ธฐ๋ก ์ถ๊ฐ
{/* ์์ต ๊ธฐ๋ก ํ
์ด๋ธ */}
์์ต ๊ธฐ๋ก
{records.length === 0 ? (
์์ต ๊ธฐ๋ก์ด ์์ต๋๋ค. ์ ํผ์ผ๋ก ์ถ๊ฐํด๋ณด์ธ์.
) : (
์์ ID
์
์์ต
์กฐํ์
RPM
{records.map(rec => (
editingId === rec.id ? (
) : (
{
setEditingId(rec.id);
setEditForm({ revenue_usd: rec.revenue_usd, views: rec.views });
}}
style={{ cursor: 'pointer' }}
>
{rec.yt_video_id.slice(0, 11)}
{rec.record_month}
${rec.revenue_usd?.toFixed(2)}
{rec.views?.toLocaleString()}
{rec.views > 0
? `$${((rec.revenue_usd / rec.views) * 1000).toFixed(2)}`
: 'โ'}
)
))}
)}
);
}
```
- [ ] **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 (
{/* ์์ง ์ํ ๋ฐ */}
๋ง์ง๋ง ์์ง: {latestReport?.report_date ?? '์์'}
{latestReport && ` ยท ${latestReport.top_genres?.length ?? 0}๊ฐ ์ฅ๋ฅด`}
{/* ์ธ๊ธฐ ์ฅ๋ฅด Top 5 */}
๐ฅ ์ค๋์ ์ธ๊ธฐ ์ฅ๋ฅด Top 5
{topGenres.length === 0 ? (
ํธ๋ ๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ์๋ ์์ง์ ์คํํ๊ฑฐ๋ agent-office๊ฐ ๋ด์ผ 09:00์ ์๋ ์์งํฉ๋๋ค.
) : (
{topGenres.map((g, i) => (
#{i + 1}
{g.genre}
{(g.countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
{g.score}
))}
)}
{/* Suno ํ๋กฌํํธ ์ถ์ฒ */}
{suggestions.length > 0 && (
โจ AI ์ถ์ฒ Suno ํ๋กฌํํธ
{suggestions.map((s, i) => (
{s.genre}
{(s.target_countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
{copiedIdx === i && (
โ ๋ณต์ฌ๋จ
)}
{s.reason && (
{s.reason}
)}
))}
)}
{/* ํธ๋ ๋ ๋ฆฌํฌํธ ์ด๋ ฅ */}
๐ ํธ๋ ๋ ๋ฆฌํฌํธ ์ด๋ ฅ
{reports.length === 0 ? (
๋ฆฌํฌํธ ์ด๋ ฅ์ด ์์ต๋๋ค
) : (
{reports.map(r => (
setSelectedReport(
selectedReport?.report_date === r.report_date ? null : r
)}
>
{r.report_date}
{r.report_date === latestReport?.report_date && (
โ ์ค๋
)}
{r.top_genres?.length ?? 0}๊ฐ ์ฅ๋ฅด ยท {r.recommended_styles?.length ?? 0}๊ฐ ์ถ์ฒ
๋ณด๊ธฐ โ
))}
)}
);
}
```
- [ ] **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
```
์ด ๋ฒํผ **์๋**์ ์ถ๊ฐ:
```jsx
```
### 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ํ์ `` ๋ ๋๋ง ๋ธ๋ก์์, ๊ธฐ์กด `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'` ๋ธ๋ก์ `` ์ปดํฌ๋ํธ์ `onVideoProject` prop ์ถ๊ฐ**
ํ์ฌ 1129~1143ํ์ `` ์ปดํฌ๋ํธ์์, ๊ธฐ์กด `onVideoGenerate={handleVideoGenerate}` ์๋์:
```jsx
onVideoProject={handleVideoProject}
```
์ถ๊ฐํ๋ค.
### 6-G: YouTube ํญ ๋ฒํผ ์ถ๊ฐ
- [ ] **Step 9: ํญ nav์ YouTube ํญ ๋ฒํผ ์ถ๊ฐ**
ํ์ฌ 1117~1123ํ (Remix ํญ ๋ฒํผ):
```jsx
```
์ด ๋ฒํผ **๋ค์**์ ์ถ๊ฐ:
```jsx
```
### 6-H: YouTube ํญ ์ฝํ
์ธ ๋ ๋๋ง ์ถ๊ฐ
- [ ] **Step 10: Remix ํญ ๋ ๋ ๋ธ๋ก ๋ค์์ YouTube ํญ ๋ ๋ ๋ธ๋ก ์ถ๊ฐ**
ํ์ฌ 1151~1167ํ (Remix ํญ ๋ ๋):
```jsx
{/* โโโ REMIX TAB โโโ */}
{tab === 'remix' && (
)}
```
์ด ๋ธ๋ก **๋ค์**์ ์ถ๊ฐ:
```jsx
{/* โโโ YOUTUBE TAB โโโ */}
{tab === 'youtube' && (
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
```