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