Files
web-page/src/pages/music/components/VideoProjectsTab.jsx

270 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}