Files
web-page-backend/docs/superpowers/plans/2026-05-01-music-youtube-tab-frontend.md
2026-05-01 14:40:04 +09:00

57 KiB

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 브랜치 생성

cd /Users/jaeohpark/development/web-page
git checkout -b feat/music-youtube-tab
  • Step 2: src/api.js 파일 끝에 YouTube/Revenue/Trends API 함수 추가

현재 파일은 628행. 파일 끝(triggerLottoCurate 함수 닫는 브레이스 다음)에 아래를 추가한다.

// ── 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 서버 시작 확인.
cd /Users/jaeohpark/development/web-page
npm run dev

Expected: 콘솔에 에러 없이 http://localhost:5173 (또는 포트 출력) 기동.

  • Step 4: 커밋
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 생성

// 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: 커밋
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 생성
// 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: 커밋
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 생성

// 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: 커밋
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 생성

// 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: 커밋
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 근처). 그 아래에 추가:

import YoutubeTab from './components/YoutubeTab';

기존 import 블록 예시 (3~10행 근처):

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행:

const [tab, setTab] = useState('create');

이 바로 아래에 추가:

const [initialTrackId, setInitialTrackId] = useState(null);

6-C: LibraryCard에 YouTube 프로젝트 버튼 추가

  • Step 3: LibraryCard 컴포넌트 props에 onVideoProject 추가

현재 340행:

const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating }) => {

이를 아래로 교체:

const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating }) => {
  • Step 4: hasSunoId 조건 블록의 ••• 드롭다운 안에 YouTube 프로젝트 버튼 추가

현재 426~427행 (🎬 Music Video 버튼 다음):

<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
    disabled={isGenerating}>🎬 Music Video</button>

이 버튼 아래에 추가:

<button type="button" onClick={() => { onVideoProject(track); setMenuOpen(false); }}>
    🎯 YouTube 프로젝트
</button>

6-D: Library 컴포넌트에 onVideoProject prop 전달

  • Step 5: Library 컴포넌트 props에 onVideoProject 추가

현재 450행:

const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => {

이를 아래로 교체:

const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating, loading }) => {
  • Step 6: Library 내부의 LibraryCard 렌더링에 onVideoProject prop 추가

현재 491~515행의 <LibraryCard> 렌더링 블록에서, 기존 onVideoGenerate={onVideoGenerate} 아래에:

onVideoProject={onVideoProject}

추가한다.

6-E: MusicStudio 함수 내 핸들러 추가

  • Step 7: handleVideoGenerate 핸들러 근처에 handleVideoProject 핸들러 추가

handleVideoGenerate 함수를 찾아 (파일 내 onVideoGenerate 콜백 정의 위치) 그 바로 아래에 추가:

const handleVideoProject = (track) => {
    setInitialTrackId(track.id);
    setTab('youtube');
};

6-F: Library 렌더링 블록에 prop 연결

  • Step 8: tab === 'library' 블록의 <Library> 컴포넌트에 onVideoProject prop 추가

현재 1129~1143행의 <Library> 컴포넌트에서, 기존 onVideoGenerate={handleVideoGenerate} 아래에:

onVideoProject={handleVideoProject}

추가한다.

6-G: YouTube 탭 버튼 추가

  • Step 9: 탭 nav에 YouTube 탭 버튼 추가

현재 1117~1123행 (Remix 탭 버튼):

<button
    type="button"
    className={`ms-tab ${tab === 'remix' ? 'is-active' : ''}`}
    onClick={() => setTab('remix')}
>
    <span className="ms-tab__icon">🔄</span> Remix
</button>

이 버튼 다음에 추가:

<button
    type="button"
    className={`ms-tab ms-tab--youtube ${tab === 'youtube' ? 'is-active' : ''}`}
    onClick={() => setTab('youtube')}
>
    <span className="ms-tab__icon">🎯</span> YouTube
</button>

6-H: YouTube 탭 콘텐츠 렌더링 추가

  • Step 10: Remix 탭 렌더 블록 다음에 YouTube 탭 렌더 블록 추가

현재 1151~1167행 (Remix 탭 렌더):

{/* ═══ REMIX TAB ═══ */}
{tab === 'remix' && (
    <RemixTab ... />
)}

이 블록 다음에 추가:

{/* ═══ YOUTUBE TAB ═══ */}
{tab === 'youtube' && (
    <YoutubeTab
        library={library}
        initialTrackId={initialTrackId}
        onClearInitialTrack={() => setInitialTrackId(null)}
    />
)}

6-I: CSS 추가

  • Step 11: MusicStudio.css 파일 끝에 YouTube 탭 스타일 추가
/* ══════════════════════════════════════════
   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: 커밋
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 서버 시작 (이미 실행 중이면 스킵)
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 준비
cd /Users/jaeohpark/development/web-page
git log --oneline feat/music-youtube-tab ^main

Expected: Task 1~6에서 만든 커밋 6개 표시.

git push -u origin feat/music-youtube-tab