diff --git a/web/pqueue.js b/web/pqueue.js index 4ee0c76..70634b1 100644 --- a/web/pqueue.js +++ b/web/pqueue.js @@ -24,282 +24,400 @@ selectedPending: new Set(), }; - function el(tag, attrs={}, children=[]) { - const e = document.createElement(tag); - Object.entries(attrs).forEach(([k,v]) => { - if (k === 'class') e.className = v; else if (k === 'text') e.textContent = v; else e.setAttribute(k, v); - }); - children.forEach(c => e.appendChild(c)); - return e; - } + const Progress = { + computeAggregateProgress(nodes) { + let totalMax = 0; + let totalVal = 0; + Object.values(nodes ?? {}).forEach((st) => { + const mv = Math.max(1, Number(st?.max ?? 1)); + const vv = Math.max(0, Math.min(Number(st?.value ?? 0), mv)); + totalMax += mv; + totalVal += vv; + }); + return totalMax > 0 ? Math.max(0, Math.min(1, totalVal / totalMax)) : 0; + }, + }; - // Progress helpers - function computeAggregateProgress(nodes) { - let totalMax = 0; - let totalVal = 0; - Object.values(nodes || {}).forEach((st) => { - const mv = Math.max(1, Number((st && st.max) || 1)); - const vv = Math.max(0, Math.min(Number((st && st.value) || 0), mv)); - totalMax += mv; - totalVal += vv; - }); - return totalMax > 0 ? Math.max(0, Math.min(1, totalVal / totalMax)) : 0; - } + const UI = { + el(tag, attrs = {}, children = []) { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') { + e.className = v; + } else if (k === 'text') { + e.textContent = v; + } else { + e.setAttribute(k, v); + } + } + for (const c of children) { + e.appendChild(c); + } + return e; + }, - function updateProgressBarsFromState() { - if (!state.container) return; - Object.entries(state.running_progress).forEach(([pid, f]) => { - const bar = state.container.querySelector(`.pqueue-item[data-id="${pid}"] .pqueue-progress-bar`); - if (bar) bar.style.width = `${(Number(f) * 100).toFixed(2)}%`; - }); - } + createButton({ id, text, icon, classes, onClick }) { + const button = UI.el('button', { id, class: `p-button p-component ${classes}` }); + if (icon) { + button.appendChild(UI.el('span', { class: `p-button-icon pi ${icon}` })); + } + if (text) { + button.appendChild(UI.el('span', { class: 'p-button-label', text })); + } + if (onClick) { + button.onclick = onClick; + } + return button; + }, - function ensureThemeLink() { - if (!document.getElementById('pqueue-style-link')) { - const link = document.createElement('link'); - link.id = 'pqueue-style-link'; - link.rel = 'stylesheet'; - link.href = '/extensions/ComfyUI-PersistentQueue/css/queue_style.css'; - document.head.appendChild(link); + createCard(title, content) { + return UI.el('div', { class: 'p-card pqueue-card' }, [ + UI.el('div', { class: 'p-card-body' }, [ + UI.el('div', { class: 'p-card-title', text: title }), + content, + ]), + ]); + }, + + updateProgressBarsFromState() { + if (!state.container) return; + Object.entries(state.running_progress).forEach(([pid, f]) => { + const bar = state.container.querySelector(`.pqueue-item[data-id="${pid}"] .pqueue-progress-bar`); + if (bar) bar.style.width = `${(Number(f) * 100).toFixed(2)}%`; + }); + }, + + ensureThemeLink() { + if (!document.getElementById('pqueue-style-link')) { + const link = document.createElement('link'); + link.id = 'pqueue-style-link'; + link.rel = 'stylesheet'; + link.href = '/extensions/ComfyUI-PersistentQueue/css/queue_style.css'; + document.head.appendChild(link); + } + }, + + renderToolbar() { + const pauseBtn = UI.createButton({ + id: 'pqueue-toggle', + text: state.paused ? 'Resume' : 'Pause', + icon: state.paused ? 'pi-play' : 'pi-pause', + classes: state.paused ? 'p-button-success' : 'p-button-warning', + }); + const refreshBtn = UI.createButton({ + id: 'pqueue-refresh', + text: 'Refresh', + icon: 'pi-refresh', + classes: 'p-button-text', + }); + const clearBtn = UI.createButton({ + id: 'pqueue-clear', + text: 'Clear Pending', + icon: 'pi-stop', + classes: 'p-button-text p-button-danger', + }); + + return UI.el('div', { class: 'pqueue-toolbar' }, [pauseBtn, refreshBtn, clearBtn]); + }, + + renderRunningSection() { + const runningList = UI.el('ul', { class: 'pqueue-list' }); + if (!state.queue_running.length) { + runningList.appendChild(UI.el('li', { class: 'pqueue-empty', text: 'No running job' })); + } else { + state.queue_running.forEach(item => { + const pid = item[1]; + const frac = Math.max(0, Math.min(1, Number(state.running_progress?.[pid]) || 0)); + const progressBar = UI.el('div', { class: 'pqueue-progress-bar' }); + progressBar.style.width = `${(frac * 100).toFixed(2)}%`; + + const li = UI.el('li', { class: 'pqueue-item', 'data-id': pid }, [ + UI.el('div', { class: 'pqueue-left' }, [ + UI.el('div', { class: 'pqueue-meta' }, [ + UI.el('i', { class: 'pi pi-spin pi-spinner' }), + UI.el('span', { text: pid }) + ]), + UI.el('div', { class: 'pqueue-progress-wrap' }, [progressBar]), + ]), + UI.el('div', { class: 'pqueue-actions' }, []) + ]); + runningList.appendChild(li); + }); + } + return UI.createCard('Running', runningList); + }, + + renderPendingSection() { + const pendingList = UI.el('ul', { id: 'pqueue-pending', class: 'pqueue-list' }); + if (!state.queue_pending.length) { + pendingList.appendChild(UI.el('li', { class: 'pqueue-empty', text: 'No pending items' })); + } else { + state.queue_pending.forEach(item => { + const checked = state.selectedPending.has(item[1]); + const checkbox = UI.el('input', { type: 'checkbox', class: 'p-checkbox-input pqueue-select' }); + if (checked) checkbox.checked = true; + + const priorityInput = UI.el('input', { type: 'number', value: '0', class: 'p-inputtext pqueue-priority', min: '0', style: 'width:5rem' }); + const savePriorityBtn = UI.createButton({ + icon: 'pi-sort-amount-up-alt', + classes: 'p-button-text pqueue-priority-save', + }); + const deleteBtn = UI.createButton({ + icon: 'pi-trash', + classes: 'p-button-text p-button-danger pqueue-delete', + }); + + const li = UI.el('li', { draggable: 'true', 'data-id': item[1], class: 'pqueue-item' }, [ + UI.el('div', { class: 'pqueue-meta' }, [ + UI.el('i', { class: 'pi pi-bars', style: 'cursor:grab' }), + checkbox, + UI.el('span', { text: item[1] }) + ]), + UI.el('div', { class: 'pqueue-actions' }, [ + priorityInput, + savePriorityBtn, + deleteBtn, + ]) + ]); + pendingList.appendChild(li); + }); + } + return UI.createCard(`Pending (${state.queue_pending.length})`, pendingList); + }, + + renderHistorySection() { + const historyList = UI.el('ul', { class: 'pqueue-list' }); + if (!state.history.length) { + historyList.appendChild(UI.el('li', { class: 'pqueue-empty', text: 'No history' })); + } else { + state.history.forEach(row => { + const ok = (row.status ?? 'success') === 'success'; + const thumbs = UI.buildResultThumbs(row); + const time = UI.buildDuration(row); + const li = UI.el('li', { class: 'pqueue-item' }, [ + UI.el('div', { class: 'pqueue-meta' }, [ + UI.el('i', { class: `pi ${ok ? 'pi-check pqueue-status-success' : 'pi-times pqueue-status-error'}` }), + (time ? UI.el('span', { class: 'task-time', text: time }) : UI.el('span', {})) + ]), + UI.el('div', { class: 'pqueue-thumbs-grid' }, thumbs.children.length ? Array.from(thumbs.children) : []) + ]); + historyList.appendChild(li); + }); + } + return UI.createCard('History', historyList); + }, + + render() { + if (!state.container) return; + UI.ensureThemeLink(); + state.container.innerHTML = ''; + + const root = UI.el('div', { class: 'pqueue-root' }); + const toolbar = UI.renderToolbar(); + const sections = UI.el('div', { class: 'pqueue-sections' }); + + sections.appendChild(UI.renderRunningSection()); + sections.appendChild(UI.el('div', { class: 'pqueue-divider' })); + sections.appendChild(UI.renderPendingSection()); + sections.appendChild(UI.el('div', { class: 'pqueue-divider' })); + sections.appendChild(UI.renderHistorySection()); + + root.appendChild(toolbar); + root.appendChild(sections); + state.container.appendChild(root); + + Events.init(); + UI.updateProgressBarsFromState(); + }, + + buildResultThumbs(row) { + try { + const wrap = UI.el('div', { class: 'flex gap-1 flex-wrap' }); + let outputs = row.outputs ?? {}; + if (typeof outputs === 'string') { + try { outputs = JSON.parse(outputs); } catch(e) { outputs = {}; } + } + const images = []; + Object.values(outputs).forEach(v => { + if (v && typeof v === 'object') { + const imgs = v.images ?? v.ui?.images ?? []; + if (Array.isArray(imgs)) imgs.forEach(i => images.push(i)); + } else if (Array.isArray(v)) { + v.forEach(i => { if (i?.filename || i?.name) images.push(i); }); + } + }); + images.slice(0, 4).forEach(i => { + const filename = i.filename ?? i.name ?? ''; + const type = i.type ?? 'output'; + const subfolder = i.subfolder ?? ''; + const url = new URL('/view', window.location.origin); + url.searchParams.set('filename', filename); + url.searchParams.set('type', type); + if (subfolder) url.searchParams.set('subfolder', subfolder); + url.searchParams.set('preview', 'webp;50'); + const img = UI.el('img', { src: url.href, class: 'pqueue-thumb', title: filename }); + img.onclick = () => UI.openGallery(filename, type, subfolder); + wrap.appendChild(img); + }); + return wrap; + } catch(e) { return UI.el('span', {}); } + }, + + buildDuration(row) { + try { + const dur = Number(row.duration_seconds); + if (!Number.isNaN(dur) && dur > 0) { + return `${dur.toFixed(2)}s`; + } + const started = row.created_at ?? row.started_at; + const completed = row.completed_at; + if (!started || !completed) return ''; + const t1 = new Date(started).getTime(); + const t2 = new Date(completed).getTime(); + if (!isFinite(t1) || !isFinite(t2)) return ''; + const sec = Math.max(0, (t2 - t1) / 1000); + return `${sec.toFixed(2)}s`; + } catch(e) { return ''; } + }, + + openGallery(filename, type, subfolder) { + const url = new URL('/view', window.location.origin); + url.searchParams.set('filename', filename); + url.searchParams.set('type', type); + if (subfolder) url.searchParams.set('subfolder', subfolder); + window.open(url.href, '_blank'); + }, + + ensureStyles() { + if (document.getElementById('pqueue-styles')) return; + const style = document.createElement('style'); + style.id = 'pqueue-styles'; + style.textContent = ` + #pqueue-fab{position:fixed;right:16px;bottom:16px;z-index:99999;background:#2d2f34;color:#fff;border:none;border-radius:20px;padding:8px 12px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.35)} + #pqueue-panel{position:fixed;right:16px;top:64px;z-index:99998;width:420px;max-width:90vw;height:65vh;background:#1c1d20;color:#ddd;border:1px solid #333;border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,0.5);display:none;flex-direction:column} + #pqueue-panel header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid #333} + #pqueue-panel .pqueue-content{padding:8px 12px;overflow:auto;height:100%} + #pqueue-panel ul{list-style:none;margin:0;padding:0} + #pqueue-panel li{display:flex;gap:8px;align-items:center;padding:6px 4px;border-bottom:1px dashed #2c2c2c} + #pqueue-panel .pqueue-item{cursor:grab} + #pqueue-panel .pqueue-section{margin-bottom:10px} + `; + document.head.appendChild(style); + }, + + mount() { + if (state.container) return; + UI.ensureStyles(); + // Floating button + const fab = document.createElement('button'); + fab.id = 'pqueue-fab'; + fab.textContent = 'Persistent Queue'; + fab.onclick = () => { + const panel = document.getElementById('pqueue-panel'); + if (!panel) return; + const visible = panel.style.display !== 'none'; + panel.style.display = visible ? 'none' : 'flex'; + if (!visible) refresh(); + }; + document.body.appendChild(fab); + + const panel = UI.el('div', { id: 'pqueue-panel', class: 'pqueue-panel' }); + const header = document.createElement('header'); + const title = document.createElement('div'); + title.textContent = 'Persistent Queue'; + const close = document.createElement('button'); + close.textContent = '×'; + close.onclick = () => { panel.style.display = 'none'; }; + header.appendChild(title); + header.appendChild(close); + panel.appendChild(header); + state.container = UI.el('div', { class: 'pqueue-content' }); + panel.appendChild(state.container); + document.body.appendChild(panel); + refresh(); } - } + }; - function render() { - if (!state.container) return; - ensureThemeLink(); - state.container.innerHTML = ''; - - const root = el('div', { class: 'pqueue-root' }); - - // Toolbar - const toolbar = el('div', { class: 'pqueue-toolbar' }); - const pauseBtn = el('button', { id: 'pqueue-toggle', class: `p-button p-component ${state.paused ? 'p-button-success' : 'p-button-warning'}` }); - pauseBtn.innerHTML = `${state.paused ? 'Resume' : 'Pause'}`; - const refreshBtn = el('button', { id: 'pqueue-refresh', class: 'p-button p-component p-button-text' }); - refreshBtn.innerHTML = `Refresh`; - const clearBtn = el('button', { id: 'pqueue-clear', class: 'p-button p-component p-button-text p-button-danger' }); - clearBtn.innerHTML = `Clear Pending`; - toolbar.appendChild(pauseBtn); - toolbar.appendChild(refreshBtn); - toolbar.appendChild(clearBtn); - - const sections = el('div', { class: 'pqueue-sections' }); - - // Running - const runningCard = el('div', { class: 'p-card pqueue-card' }); - const runningBody = el('div', { class: 'p-card-body' }); - runningBody.appendChild(el('div', { class: 'p-card-title', text: 'Running' })); - const runningList = el('ul', { class: 'pqueue-list' }); - if (!state.queue_running.length) runningList.appendChild(el('li', { class: 'pqueue-empty', text: 'No running job' })); - state.queue_running.forEach(item => { - const pid = item[1]; - const leftCol = el('div', { class: 'pqueue-left' }, [ - el('div', { class: 'pqueue-meta' }, [ - el('i', { class: 'pi pi-spin pi-spinner' }), - el('span', { text: pid }) - ]), - (() => { - const wrap = el('div', { class: 'pqueue-progress-wrap' }); - const bar = el('div', { class: 'pqueue-progress-bar' }); - const frac = Math.max(0, Math.min(1, Number(state.running_progress && state.running_progress[pid]) || 0)); - bar.style.width = `${(frac * 100).toFixed(2)}%`; - wrap.appendChild(bar); - return wrap; - })() - ]); - const li = el('li', { class: 'pqueue-item', 'data-id': pid }, [ - leftCol, - el('div', { class: 'pqueue-actions' }, []) - ]); - runningList.appendChild(li); - }); - runningBody.appendChild(runningList); - runningCard.appendChild(runningBody); - - // Pending - const pendingCard = el('div', { class: 'p-card pqueue-card' }); - const pendingBody = el('div', { class: 'p-card-body' }); - pendingBody.appendChild(el('div', { class: 'p-card-title', text: `Pending (${state.queue_pending.length})` })); - const pendingList = el('ul', { id: 'pqueue-pending', class: 'pqueue-list' }); - if (!state.queue_pending.length) pendingList.appendChild(el('li', { class: 'pqueue-empty', text: 'No pending items' })); - state.queue_pending.forEach(item => { - const checked = state.selectedPending.has(item[1]); - const li = el('li', { draggable: 'true', 'data-id': item[1], class: 'pqueue-item' }, [ - el('div', { class: 'pqueue-meta' }, [ - el('i', { class: 'pi pi-bars', style: 'cursor:grab' }), - (() => { const cb = el('input', { type: 'checkbox', class: 'p-checkbox-input pqueue-select' }); if (checked) cb.checked = true; return cb; })(), - el('span', { text: item[1] }) - ]), - el('div', { class: 'pqueue-actions' }, [ - el('input', { type: 'number', value: '0', class: 'p-inputtext pqueue-priority', min: '0', style: 'width:5rem' }), - (() => { const b=el('button', { class: 'p-button p-component p-button-text pqueue-priority-save' }); b.innerHTML=''; return b; })(), - (() => { const b=el('button', { class: 'p-button p-component p-button-text p-button-danger pqueue-delete' }); b.innerHTML=''; return b; })(), - ]) - ]); - pendingList.appendChild(li); - }); - pendingBody.appendChild(pendingList); - pendingCard.appendChild(pendingBody); - - // History - const historyCard = el('div', { class: 'p-card pqueue-card' }); - const historyBody = el('div', { class: 'p-card-body' }); - historyBody.appendChild(el('div', { class: 'p-card-title', text: 'History' })); - const historyList = el('ul', { class: 'pqueue-list' }); - if (!state.history.length) historyList.appendChild(el('li', { class: 'pqueue-empty', text: 'No history' })); - state.history.forEach(row => { - const ok = (row.status||'success') === 'success'; - const thumbs = buildResultThumbs(row); - const time = buildDuration(row); - const li = el('li', { class: 'pqueue-item' }, [ - el('div', { class: 'pqueue-meta' }, [ - el('i', { class: `pi ${ok ? 'pi-check pqueue-status-success' : 'pi-times pqueue-status-error'}` }), - (time ? el('span', { class: 'task-time', text: time }) : el('span', {})) - ]), - el('div', { class: 'pqueue-thumbs-grid' }, thumbs.children.length ? Array.from(thumbs.children) : []) - ]); - historyList.appendChild(li); - }); - historyBody.appendChild(historyList); - historyCard.appendChild(historyBody); - - sections.appendChild(runningCard); - sections.appendChild(el('div', { class: 'pqueue-divider' })); - sections.appendChild(pendingCard); - sections.appendChild(el('div', { class: 'pqueue-divider' })); - sections.appendChild(historyCard); - - root.appendChild(toolbar); - root.appendChild(sections); - state.container.appendChild(root); - - wireHandlers(); - updateProgressBarsFromState(); - } - - function wireHandlers() { - document.getElementById('pqueue-toggle').onclick = async () => { + const Events = { + async onPauseClick() { if (state.paused) await API.resume(); else await API.pause(); await refresh(); - }; - document.getElementById('pqueue-refresh').onclick = refresh; - const clearBtn = document.getElementById('pqueue-clear'); - if (clearBtn) { - clearBtn.onclick = async () => { - const ids = Array.from(document.querySelectorAll('#pqueue-pending li[data-id]')).map(li => li.dataset.id); - if (ids.length) { - await API.del(ids); - await refresh(); - } - }; - } + }, - const list = document.getElementById('pqueue-pending'); - let dragged; - list.addEventListener('dragstart', (e) => { + async onClearClick() { + const ids = Array.from(document.querySelectorAll('#pqueue-pending li[data-id]')).map(li => li.dataset.id); + if (ids.length) { + await API.del(ids); + await refresh(); + } + }, + + onPendingListItemCheckboxChange(e) { const li = e.target.closest('li'); - dragged = li; - e.dataTransfer.setData('text/plain', li.dataset.id); - }); - list.addEventListener('dragover', (e) => e.preventDefault()); - list.addEventListener('drop', async (e) => { - e.preventDefault(); - const target = e.target.closest('li'); - if (!dragged || !target || dragged === target) return; - const rect = target.getBoundingClientRect(); - const before = (e.clientY - rect.top) < rect.height / 2; - if (before) list.insertBefore(dragged, target); else list.insertBefore(dragged, target.nextSibling); - const order = Array.from(list.querySelectorAll('li')).map(li => li.dataset.id); - await API.reorder(order); + const id = li.dataset.id; + if (e.target.checked) state.selectedPending.add(id); else state.selectedPending.delete(id); + }, + + async onPendingListItemPrioritySaveClick(e) { + const li = e.target.closest('li'); + const id = li.dataset.id; + const val = parseInt(li.querySelector('.pqueue-priority').value || '0', 10); + await API.setPriority(id, val); await refresh(); - }); + }, - list.querySelectorAll('.pqueue-select').forEach(cb => { - cb.onchange = (e) => { - const li = e.target.closest('li'); - const id = li.dataset.id; - if (e.target.checked) state.selectedPending.add(id); else state.selectedPending.delete(id); - }; - }); + async onPendingListItemDeleteClick(e) { + const li = e.target.closest('li'); + const id = li.dataset.id; + await API.del([id]); + await refresh(); + }, - list.querySelectorAll('.pqueue-priority-save').forEach(btn => { - btn.onclick = async (e) => { - const li = e.target.closest('li'); - const id = li.dataset.id; - const val = parseInt(li.querySelector('.pqueue-priority').value || '0', 10); - await API.setPriority(id, val); - await refresh(); - }; - }); - - list.querySelectorAll('.pqueue-delete').forEach(btn => { - btn.onclick = async (e) => { - const li = e.target.closest('li'); - const id = li.dataset.id; - await API.del([id]); - await refresh(); - }; - }); - } - - function buildResultThumbs(row) { - try { - const wrap = el('div', { class: 'flex gap-1 flex-wrap' }); - let outputs = row.outputs || {}; - if (typeof outputs === 'string') { - try { outputs = JSON.parse(outputs); } catch(e) { outputs = {}; } + init() { + document.getElementById('pqueue-toggle').onclick = Events.onPauseClick; + document.getElementById('pqueue-refresh').onclick = refresh; + const clearBtn = document.getElementById('pqueue-clear'); + if (clearBtn) { + clearBtn.onclick = Events.onClearClick; } - const images = []; - Object.values(outputs).forEach(v => { - if (v && typeof v === 'object') { - const imgs = (v.images) || (v.ui && v.ui.images) || []; - if (Array.isArray(imgs)) imgs.forEach(i => images.push(i)); - } else if (Array.isArray(v)) { - v.forEach(i => { if (i && (i.filename || i.name)) images.push(i); }); + + const list = document.getElementById('pqueue-pending'); + if (!list) return; + + let dragged; + list.addEventListener('dragstart', (e) => { + const li = e.target.closest('li'); + dragged = li; + e.dataTransfer.setData('text/plain', li.dataset.id); + }); + list.addEventListener('dragover', (e) => e.preventDefault()); + list.addEventListener('drop', async (e) => { + e.preventDefault(); + const target = e.target.closest('li'); + if (!dragged || !target || dragged === target) return; + const rect = target.getBoundingClientRect(); + const before = (e.clientY - rect.top) < rect.height / 2; + if (before) list.insertBefore(dragged, target); else list.insertBefore(dragged, target.nextSibling); + const order = Array.from(list.querySelectorAll('li')).map(li => li.dataset.id); + await API.reorder(order); + await refresh(); + }); + + list.addEventListener('change', (e) => { + if (e.target.classList.contains('pqueue-select')) { + Events.onPendingListItemCheckboxChange(e); } }); - images.slice(0, 4).forEach(i => { - const filename = i.filename || i.name || ''; - const type = i.type || 'output'; - const subfolder = i.subfolder || ''; - const url = `/view?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}${subfolder?`&subfolder=${encodeURIComponent(subfolder)}`:''}&preview=webp;50`; - const img = el('img', { src: url, class: 'pqueue-thumb', title: filename }); - img.onclick = () => openGallery(filename, type, subfolder); - wrap.appendChild(img); + + list.addEventListener('click', (e) => { + if (e.target.closest('.pqueue-priority-save')) { + Events.onPendingListItemPrioritySaveClick(e); + } else if (e.target.closest('.pqueue-delete')) { + Events.onPendingListItemDeleteClick(e); + } }); - return wrap; - } catch(e) { return el('span', {}); } - } - - function buildDuration(row) { - try { - const dur = Number(row.duration_seconds); - if (!Number.isNaN(dur) && dur > 0) { - return `${dur.toFixed(2)}s`; - } - const started = row.created_at || row.started_at; - const completed = row.completed_at; - if (!started || !completed) return ''; - const t1 = new Date(started).getTime(); - const t2 = new Date(completed).getTime(); - if (!isFinite(t1) || !isFinite(t2)) return ''; - const sec = Math.max(0, (t2 - t1) / 1000); - return `${sec.toFixed(2)}s`; - } catch(e) { return ''; } - } - - function openGallery(filename, type, subfolder) { - try { - if (window.app && window.app.extensionManager && window.app.extensionManager.dialog) { - // Use the built-in image preview route; the core gallery opens via dialogs in the frontend - const url = `/view?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}${subfolder?`&subfolder=${encodeURIComponent(subfolder)}`:''}`; - window.open(url, '_blank'); - return; - } - } catch(e) {} - const url = `/view?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}${subfolder?`&subfolder=${encodeURIComponent(subfolder)}`:''}`; - window.open(url, '_blank'); - } + } + }; async function refresh() { const q = await API.getQueue();