feat(youtube-tab): VideoProjectsTab 영상 제작 서브탭
This commit is contained in:
@@ -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 }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user