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 }) {
|
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