feat(youtube-tab): VideoProjectsTab 영상 제작 서브탭

This commit is contained in:
2026-05-01 14:46:27 +09:00
parent a89de57b79
commit 16b8cc59ae

View File

@@ -1,3 +1,269 @@
// 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 }) { export default function VideoProjectsTab({ library, initialTrackId, onClearInitialTrack }) {
return null; 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>
);
} }