diff --git a/docs/superpowers/plans/2026-05-01-music-youtube-tab-frontend.md b/docs/superpowers/plans/2026-05-01-music-youtube-tab-frontend.md new file mode 100644 index 0000000..1e1a2c5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-music-youtube-tab-frontend.md @@ -0,0 +1,1761 @@ +# 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 +```