270 lines
10 KiB
JavaScript
270 lines
10 KiB
JavaScript
// 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>
|
||
);
|
||
}
|