// 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) ?
{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 && (
{statusLabel}…
)} {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 ? (
{/* 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) ?
)} {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 => ( ))}
) : (
{t('tpl.emptyHistory')}
)}
); }; Object.assign(window, { TemplateUsageScreen });