// template-usage.jsx — Template usage page (/template/:id)
// Left: template list | Middle: upload + create | Right: task results
const _tplToken = () => window.YiteAPI?.getToken?.() || localStorage.getItem('token') || '';
const _tplAPI = {
list: (page = 1) =>
fetch('/api/v1/templates?category=template&page_size=50&page=' + page).then(r => r.json()),
get: id =>
fetch('/api/v1/templates/' + id).then(r => r.json()),
upload: file => {
const fd = new FormData(); fd.append('file', file); fd.append('parent_id', '');
return fetch('/api/v1/assets/upload', { method: 'POST', headers: { Authorization: 'Bearer ' + _tplToken() }, body: fd }).then(r => r.json());
},
createTask: body =>
fetch('/api/v1/create-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + _tplToken() },
body: JSON.stringify(body),
}).then(r => r.json()),
listTasks: (templateId) =>
fetch('/api/v1/create-tasks?media_type=template' + (templateId ? '&template_id=' + encodeURIComponent(templateId) : ''), { headers: { Authorization: 'Bearer ' + _tplToken() } }).then(r => r.json()),
publishWork: body =>
fetch('/api/v1/templates', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + _tplToken() },
body: JSON.stringify(body),
}).then(r => r.json()),
unpublish: templateId =>
fetch('/api/v1/templates/' + templateId + '/unpublish', {
method: 'POST', headers: { Authorization: 'Bearer ' + _tplToken() },
}).then(r => r.json()),
uploadCover: file => {
const fd = new FormData(); fd.append('file', file);
return fetch('/api/v1/templates/cover', { method: 'POST', headers: { Authorization: 'Bearer ' + _tplToken() }, body: fd }).then(r => r.json());
},
deleteTask: id =>
fetch('/api/v1/create-tasks/' + id, { method: 'DELETE', headers: { Authorization: 'Bearer ' + _tplToken() } }).then(r => r.json()),
};
const _tplAssetList = (parentID = '', tag = '') => {
let url = '/api/v1/assets?parent_id=' + encodeURIComponent(parentID);
if (tag) url += '&tag=' + encodeURIComponent(tag);
return fetch(url, { headers: { Authorization: 'Bearer ' + _tplToken() } }).then(r => r.json());
};
// ─── Asset picker modal (select from library) ───────────────────────────────
const TplAssetPicker = ({ max = 6, existing = 0, onSelect, onClose }) => {
const { t } = useI18n();
const [folders, setFolders] = React.useState([]);
const [files, setFiles] = React.useState([]);
const [path, setPath] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [selected, setSelected] = React.useState(new Set());
const uploadRef = React.useRef(null);
const parentID = path.length > 0 ? path[path.length - 1].id : '';
const remaining = max - existing;
const fetchItems = async () => {
setLoading(true);
const res = await _tplAssetList(parentID, '');
const items = res.items || [];
setFolders(items.filter(i => i.is_folder));
setFiles(items.filter(i => !i.is_folder && i.tag === 'image'));
setLoading(false);
};
React.useEffect(() => { fetchItems(); }, [parentID]);
const toggleSelect = id => {
setSelected(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : (next.size < remaining && next.add(id)); return next; });
};
const handleUpload = async e => {
const f = e.target.files?.[0]; e.target.value = '';
if (!f) return;
setLoading(true);
await _tplAPI.upload(f);
fetchItems();
};
const handleConfirm = () => {
const picked = files.filter(f => selected.has(f.id)).map(f => ({ id: f.id, url: f.url, name: f.name }));
onSelect(picked);
onClose();
};
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()}>
{t('tpl.selectImages')}
{t('tpl.canSelect')} {remaining - selected.size} {t('tpl.items')}
{path.map((p, i) => (
))}
{loading ? (
) : (
{folders.map(f => {
const hue = (f.name.charCodeAt(0) * 37) % 360;
return (
setPath([...path, { id: f.id, name: f.name }])} style={{ aspectRatio: '1', borderRadius: 8, border: '1px solid var(--line-2)', background: `hsl(${hue} 50% 12%)`, cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
{f.name}
);
})}
{files.map(f => {
const isSel = selected.has(f.id);
return (
toggleSelect(f.id)} style={{ aspectRatio: '1', borderRadius: 8, overflow: 'hidden', position: 'relative', border: `2px solid ${isSel ? 'var(--accent)' : 'var(--line-2)'}`, cursor: 'pointer' }}>
{f.url ?

:
}
{isSel &&
}
);
})}
)}
,
document.body
);
};
const _isVidUrl = url => /\.(mp4|mov|webm|avi|mkv)(\?|$)/i.test(url);
const _relTime = iso => {
if (!iso) return '';
if (window.relativeTime) return window.relativeTime(iso);
const d = (Date.now() - new Date(iso).getTime()) / 1000;
const t = window.t || (k => k);
if (d < 60) return t('time.justNow'); if (d < 3600) return Math.floor(d / 60) + t('time.minutesAgo');
if (d < 86400) return Math.floor(d / 3600) + t('time.hoursAgo'); if (d < 604800) return Math.floor(d / 86400) + t('time.daysAgo');
return new Date(iso).toLocaleDateString();
};
// ─── Sidebar template card ───────────────────────────────────────────────────
const TplSidebarCard = ({ tpl, active, onClick }) => {
const { t } = useI18n();
const src = tpl.cover_url || tpl.result_url;
const isVid = tpl.media_type === 'video';
return (
{ if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}>
{src && (isVid && _isVidUrl(src)
?
:

e.target.style.display = 'none'} />
)}
{tpl.title || t('tpl.untitled')}
{isVid ? t('shared.badge.video') : t('creation.image')}{tpl.use_count > 0 ? ' · ' + tpl.use_count + ' ' + t('tpl.usageCount') : ''}
);
};
// ─── Task card (simplified for template page) ────────────────────────────────
const TplTaskCard = ({ task, isCurrent, onDelete, onPublish, onUnpublish }) => {
const { t } = useI18n();
const [publishing, setPub] = React.useState(false);
const [preview, setPreview] = React.useState(null);
const isRunning = task.status === 'pending' || task.status === 'running';
const isDone = task.status === 'done';
const statusColor = task.status === 'error' ? '#f87171' : isRunning ? 'var(--accent)' : '#4ade80';
const statusLabel = task.status === 'pending' ? t('status.queued') : task.status === 'running' ? t('status.generating') : isDone ? t('status.completed') : t('status.failed');
const resultUrl = task.results?.[0];
const mp = task.model_param || {};
return (
{isCurrent && {t('tpl.currentTpl')}}
{statusLabel}
{_relTime(task.created_at)}
{[mp.aspect_ratio, mp.resolution, mp.duration ? mp.duration + 's' : null].filter(Boolean).join(' · ')}
{task.prompt &&
{task.prompt}
}
{resultUrl && (
setPreview(resultUrl)} style={{ margin: '0 16px 12px', borderRadius: 10, overflow: 'hidden', cursor: 'pointer', background: '#06080f' }}>
{_isVidUrl(resultUrl) ? (
)}
{isRunning && !resultUrl && (
)}
{task.error_msg &&
{task.error_msg}
}
{isDone && (
)}
{preview && ReactDOM.createPortal(
setPreview(null)}>
{_isVidUrl(preview) ?
, document.body
)}
);
};
// ─── Main screen ──────────────────────────────────────────────────────────────
const TemplateUsageScreen = ({ templateId }) => {
const { t } = useI18n();
const { isMobile, isTablet, w } = useBreakpoint();
const isCompact = isMobile || isTablet;
const tplSidebarW = w >= 3840 ? '360px' : w >= 2560 ? '300px' : '240px';
const [templates, setTemplates] = React.useState([]);
const [currentId, setCurrentId] = React.useState(templateId);
const [tplData, setTplData] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [imageSlots, setImageSlots] = React.useState([]); // [{role, original_url, user_url, user_name}]
const [creating, setCreating] = React.useState(false);
const [error, setError] = React.useState('');
const [tasks, setTasks] = React.useState([]);
const [taskFilter, setTaskFilter] = React.useState('current'); // 'all' | 'current'
const [pickerSlotIdx, setPickerSlotIdx] = React.useState(null); // which slot is picking
React.useEffect(() => {
_tplAPI.list().then(d => {
const items = d.items || [];
setTemplates(items);
// Auto-select: pick a random one from top popular templates
if ((!currentId || currentId === '_') && items.length > 0) {
const sorted = [...items].sort((a, b) => (b.use_count || 0) - (a.use_count || 0));
const topN = sorted.slice(0, Math.min(5, sorted.length));
const pick = topN[Math.floor(Math.random() * topN.length)];
setCurrentId(pick.id);
window.history.replaceState({}, '', '/template/' + pick.id);
}
});
}, []);
React.useEffect(() => {
if (!currentId) return;
setLoading(true); setError(''); setImageSlots([]);
_tplAPI.get(currentId).then(data => {
setTplData(data);
// Initialize image slots — prefer template's own image_list, fallback to task's
const origImages = data.image_list || data.task?.image_list || [];
if (origImages.length > 0) {
setImageSlots(origImages.map((img, i) => ({
role: img.role || 'reference_image',
original_url: img.image_url,
user_url: null,
user_name: null,
label: img.role === 'first_frame' ? t('video.startFrame') : img.role === 'last_frame' ? t('video.endFrame') : t('creation.image') + ' ' + (i + 1),
})));
} else if ((data.image_count || 0) > 0) {
setImageSlots(Array.from({ length: data.image_count }, (_, i) => ({
role: 'reference_image',
original_url: null,
user_url: null,
user_name: null,
label: t('creation.image') + ' ' + (i + 1),
})));
}
setLoading(false);
}).catch(() => { setError(t('tpl.loadFailed')); setLoading(false); });
}, [currentId]);
// Fetch tasks — all template tasks or current template only
const fetchTasks = () => {
const tplId = taskFilter === 'current' && currentId && currentId !== '_' ? currentId : '';
_tplAPI.listTasks(tplId).then(d => { if (d.items) setTasks(d.items); });
};
React.useEffect(() => { fetchTasks(); }, [currentId, taskFilter]);
React.useEffect(() => {
if (!tasks.some(t => t.status === 'pending' || t.status === 'running')) return;
const id = setInterval(fetchTasks, 5000);
return () => clearInterval(id);
}, [tasks]);
const handleSwitchTemplate = id => { setCurrentId(id); window.history.replaceState({}, '', '/template/' + id); };
const handleSlotPicked = picked => {
if (pickerSlotIdx == null || picked.length === 0) return;
setImageSlots(prev => prev.map((s, i) => i === pickerSlotIdx ? { ...s, user_url: picked[0].url, user_name: picked[0].name } : s));
setPickerSlotIdx(null);
};
const handleCreate = async () => {
if (!tplData) { setError(t('tpl.templateNotLoaded')); return; }
if (!_tplToken()) { showLoginModal(); return; }
// Check all image slots are filled
const unfilled = imageSlots.filter(s => !s.user_url);
if (unfilled.length > 0) { setError(t('tpl.missingImages') + unfilled.length + ' ' + t('tpl.items') + ')'); return; }
setCreating(true); setError('');
// Use template's own data (self-contained), fallback to task for legacy
const origTask = tplData.task;
const newImageList = imageSlots.map(s => ({ image_url: s.user_url, role: s.role }));
try {
const res = await _tplAPI.createTask({
media_type: 'template',
type: tplData.task_type || origTask?.type || (tplData.media_type === 'video' ? '参考生视频' : '参考生图'),
prompt: tplData.prompt || origTask?.prompt,
model: tplData.model_id || origTask?.model,
model_param: tplData.model_param || origTask?.model_param || {},
image_list: newImageList,
template_id: tplData.id,
folder_id: '',
});
if (res.error) { setError(res.error); setCreating(false); return; }
setCreating(false);
// Reset slots to empty for next creation
setImageSlots(prev => prev.map(s => ({ ...s, user_url: null, user_name: null })));
fetchTasks();
} catch (e) { setError(e.message); setCreating(false); }
};
const handlePublish = async task => {
const title = await ytPrompt(t('tpl.inputWorkTitle'));
if (!title) return;
let coverOssKey = '';
if (task.media_type === 'video' && task.results?.[0]) {
try {
const blob = await new Promise((resolve, reject) => {
const v = document.createElement('video'); v.crossOrigin = 'anonymous'; v.src = task.results[0];
v.onloadeddata = () => { v.currentTime = 0.1; };
v.onseeked = () => { const c = document.createElement('canvas'); c.width = v.videoWidth; c.height = v.videoHeight; c.getContext('2d').drawImage(v, 0, 0); c.toBlob(b => b ? resolve(b) : reject(), 'image/png'); };
v.onerror = () => reject();
});
const r = await _tplAPI.uploadCover(new File([blob], 'cover.png', { type: 'image/png' }));
if (r.oss_key) coverOssKey = r.oss_key;
} catch (e) {}
}
await _tplAPI.publishWork({ task_id: task.id, title, is_public: true, is_template: false, cover_oss_key: coverOssKey });
fetchTasks();
};
const handleUnpublish = async task => {
if (!task.template_id) return;
await _tplAPI.unpublish(task.template_id);
fetchTasks();
};
const handleDeleteTask = async id => { await _tplAPI.deleteTask(id); fetchTasks(); };
const tpl = tplData;
const task = tpl?.task;
const mp = task?.model_param || tpl?.model_param || {};
const needImages = tpl?.image_count || 0;
const previewSrc = tpl?.cover_url || tpl?.result_url;
const isVideo = tpl?.media_type === 'video';
const metaChips = [mp.aspect_ratio, mp.resolution, mp.duration ? mp.duration + 's' : null].filter(Boolean);
return (
{/* ── Mobile/Tablet: horizontal template strip ── */}
{isCompact && templates.length > 0 && (
{templates.map(tpl => (
))}
)}
{/* ── Left: template list (desktop only) ── */}
{!isCompact && (
)}
{/* ── Middle: template detail + upload + create ── */}
{loading ? (
) : !tpl ? (
) : (
{tpl.title || t('tpl.untitled')}
{tpl.prompt &&
{tpl.prompt}
}
{/* Preview */}
{isVideo && previewSrc ? (
) : previewSrc ? (

) : null}
{/* Image slots — fixed positions matching original task */}
{imageSlots.length > 0 && (
{t('tpl.replaceImages')}
{t('tpl.replaceInstructions')}
{imageSlots.map((slot, idx) => (
{/* Original image (faded) + user image overlay */}
setPickerSlotIdx(idx)} style={{
position: 'relative', width: isCompact ? 76 : 88, height: isCompact ? 76 : 88, borderRadius: 10, overflow: 'hidden', cursor: 'pointer',
border: slot.user_url ? '2px solid var(--accent)' : '2px dashed var(--line-2)',
transition: 'border-color .12s',
}}
onMouseEnter={e => { if (!slot.user_url) e.currentTarget.style.borderColor = 'var(--accent)'; }}
onMouseLeave={e => { if (!slot.user_url) e.currentTarget.style.borderColor = 'var(--line-2)'; }}>
{slot.user_url ? (

) : (
{slot.original_url && (_isVidUrl(slot.original_url)
?
:

e.target.style.display = 'none'} />
)}
{t('tpl.clickSelect')}
)}
{slot.user_url && (
)}
{slot.label}
))}
{pickerSlotIdx != null &&
setPickerSlotIdx(null)} />}
)}
{/* Params */}
{(task?.model_name || tpl.model_name || task?.model || tpl.model_id) &&
{t('tpl.model')}{task?.model_name || tpl.model_name || 'AI Model'}
}
{mp.aspect_ratio &&
{t('tpl.aspectRatio')}{mp.aspect_ratio}
}
{mp.resolution &&
{t('tpl.resolution')}{mp.resolution}
}
{mp.duration &&
{t('tpl.duration')}{mp.duration}s
}
{error &&
{error}
}
)}
{/* ── Right: task results ── */}
{t('tpl.creationHistory')}
{[
{ value: 'current', label: t('tpl.filterCurrent') },
{ value: 'all', label: t('tpl.filterAll') },
].map(f => (
))}
{tasks.length > 0 ? (
{tasks.map(t => (
))}
) : (
)}
);
};
Object.assign(window, { TemplateUsageScreen });