# 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 ? (

ํŠธ๋ž™์„ ์„ ํƒํ•ด ์˜์ƒ์„ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”

) : (
{projects.map(p => ( ))}
)}
{/* โ‘ข ๋‚ด๋ณด๋‚ด๊ธฐ ํŒจํ‚ค์ง€ */} {exportData && (

โ‘ข ๋‚ด๋ณด๋‚ด๊ธฐ ํŒจํ‚ค์ง€

{exportData.mp4_url && ( ๐Ÿ“น output.mp4 ๋‹ค์šด๋กœ๋“œ )} {exportData.thumbnail_url && ( ๐Ÿ–ผ๏ธ thumbnail.jpg )}
{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)}
))}
)} {/* ์ˆ˜์ต ๊ธฐ๋ก ์ถ”๊ฐ€ ํผ */}

+ ์ˆ˜์ต ๊ธฐ๋ก ์ถ”๊ฐ€

setForm(f => ({ ...f, yt_video_id: e.target.value }))} placeholder="dQw4w9WgXcQ" />
setForm(f => ({ ...f, record_month: e.target.value }))} />
setForm(f => ({ ...f, revenue_usd: e.target.value }))} placeholder="3.45" />
setForm(f => ({ ...f, views: e.target.value }))} placeholder="1200" />
{/* ์ˆ˜์ต ๊ธฐ๋ก ํ…Œ์ด๋ธ” */}

์ˆ˜์ต ๊ธฐ๋ก

{records.length === 0 ? (

์ˆ˜์ต ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค. ์œ„ ํผ์œผ๋กœ ์ถ”๊ฐ€ํ•ด๋ณด์„ธ์š”.

) : (
์˜์ƒ ID ์›” ์ˆ˜์ต ์กฐํšŒ์ˆ˜ RPM
{records.map(rec => ( editingId === rec.id ? (
{rec.yt_video_id.slice(0, 11)} {rec.record_month} setEditForm(f => ({ ...f, revenue_usd: e.target.value }))} /> setEditForm(f => ({ ...f, views: e.target.value }))} /> โ€”
) : (
{ 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 ```