// public/assets/ui.js /* English comments: shared DOM + UI helpers */ (function () { const LS_THEME = 'scmedia_theme'; let themeToggleReady = false; let queueState = { active: [], recent: [] }; let sseOpening = false; function qs(selOrId) { if (typeof selOrId !== 'string') return null; const s = selOrId.trim(); if (s === '') return null; if (s[0] === '#' || s[0] === '.' || s[0] === '[' || s.includes(' ') || s.includes('>')) { return document.querySelector(s); } return document.getElementById(s); } function qsa(selector) { return Array.from(document.querySelectorAll(selector)); } function setTheme(theme) { const mode = theme === 'light' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', mode); localStorage.setItem(LS_THEME, mode); } function initThemeToggle() { const btn = qs('themeToggle'); const saved = localStorage.getItem(LS_THEME) || 'dark'; setTheme(saved); if (!btn || themeToggleReady) return; btn.addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme') || 'dark'; setTheme(current === 'dark' ? 'light' : 'dark'); }); themeToggleReady = true; } function formatBytes(bytes) { const value = Number(bytes || 0); if (!Number.isFinite(value) || value <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let idx = 0; let n = value; while (n >= 1024 && idx < units.length - 1) { n /= 1024; idx += 1; } return `${n.toFixed(n >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`; } window.UI = { qs, qsa, setTheme, initThemeToggle, formatBytes, }; window.UI.bindThemePreference = function (onChange) { const btn = qs('themeToggle'); if (!btn || btn.dataset.themePrefBind === '1') return; btn.dataset.themePrefBind = '1'; btn.addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme') || 'dark'; window.UserPrefs?.setUi?.('theme', current); if (typeof onChange === 'function') { onChange(current); } }); }; window.UI.updateSseIndicator = function (status) { const items = document.querySelectorAll('[data-sse-indicator]'); items.forEach((el) => { el.classList.remove('sse-ok', 'sse-idle', 'sse-offline'); if (status === 'ok') el.classList.add('sse-ok'); else if (status === 'idle') el.classList.add('sse-idle'); else if (status === 'offline') el.classList.add('sse-offline'); }); }; window.UI.blinkSseIndicator = function () { const items = document.querySelectorAll('[data-sse-indicator]'); items.forEach((el) => { el.classList.add('sse-blink'); setTimeout(() => el.classList.remove('sse-blink'), 250); }); }; window.UI.startSseIndicatorPolling = function () { const updateFromStorage = () => { const last = Number(localStorage.getItem('scmedia_sse_last') || 0); const connectedRaw = localStorage.getItem('scmedia_sse_connected'); const connected = connectedRaw === '1'; if (!last && !connectedRaw) { const items = document.querySelectorAll('[data-sse-indicator]'); items.forEach((el) => { el.classList.remove('sse-ok', 'sse-idle', 'sse-offline'); }); return; } const recent = last > 0 && (Date.now() - last) <= 15000; if (recent) { window.UI.updateSseIndicator('ok'); return; } if (connected) { window.UI.updateSseIndicator('idle'); return; } window.UI.updateSseIndicator('offline'); }; updateFromStorage(); setInterval(updateFromStorage, 5000); }; window.UI.startSseClient = function () { window.Sse?.start?.(); }; window.UI.stopSseClient = function () { window.Sse?.stop?.(); }; window.UI.getSseStats = function () { const connected = localStorage.getItem('scmedia_sse_connected') === '1'; const last = Number(localStorage.getItem('scmedia_sse_last') || 0); const lastType = localStorage.getItem('scmedia_sse_last_type') || '—'; const reconnects = Number(localStorage.getItem('scmedia_sse_reconnects') || 0); return { connected, last, lastType, reconnects }; }; window.UI.initQueuePanel = function () { const summary = qs('queueSummary'); const menu = qs('queueMenu'); if (!summary || !menu) return; if (summary.dataset.queueInit === '1') return; summary.dataset.queueInit = '1'; const t = (key, fallback) => { const dict = window.I18N || {}; const v = dict && Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : null; return (typeof v === 'string' && v.length) ? v : fallback; }; const updateQueueSummary = (data) => { const activeEl = summary.querySelector('[data-queue-active]'); const errorsEl = summary.querySelector('[data-queue-errors]'); const dividerEl = summary.querySelector('[data-queue-divider]'); if (activeEl || errorsEl) { const activeLabel = activeEl?.dataset.queueActiveLabel || t('queue.active', 'Active'); const errorsLabel = errorsEl?.dataset.queueErrorsLabel || t('queue.errors', 'Errors'); const activeCount = Number(data.running || 0); const errorsCount = Number(data.errors || 0); if (activeEl) activeEl.textContent = `${activeLabel}: ${activeCount}`; if (errorsEl) { errorsEl.textContent = `${errorsLabel}: ${errorsCount}`; errorsEl.classList.toggle('is-hidden', errorsCount <= 0); } if (dividerEl) dividerEl.classList.toggle('is-hidden', errorsCount <= 0); } }; const updateJobIndicator = (job) => { const wrap = qs('job-indicator'); const prog = qs('job-indicator-progress'); if (!wrap || !prog) return; if (!job) { wrap.classList.add('hidden'); return; } wrap.classList.remove('hidden'); const total = Number(job.progress_total || 0); const cur = Number(job.progress || 0); const pct = total > 0 ? Math.floor((cur / total) * 100) : 0; prog.max = 100; prog.value = pct; }; const isMenuOpen = () => !menu.classList.contains('is-hidden'); const cancelJobById = async (id) => { if (!id) return; if (!confirm(t('job.cancel_confirm', 'Cancel job?'))) return; try { await apiJson('/api/jobs/cancel', { method: 'POST', body: JSON.stringify({ id }), headers: { 'Content-Type': 'application/json' }, }); } catch (e) { // ignore } }; const renderQueueMenu = () => { const activeWrap = qs('queueMenuActive'); const finishedWrap = qs('queueMenuFinished'); if (!activeWrap || !finishedWrap) return; activeWrap.innerHTML = ''; finishedWrap.innerHTML = ''; if (queueState.active.length === 0) { const empty = document.createElement('div'); empty.className = 'queue-item-status'; empty.textContent = t('queue.none_active', 'No active tasks'); activeWrap.appendChild(empty); } else { queueState.active.forEach(j => { const row = document.createElement('div'); row.className = 'queue-item'; const title = document.createElement('div'); const pct = j.progress_total > 0 ? Math.floor((j.progress / j.progress_total) * 100) : 0; title.textContent = `${j.title || ''} (${pct}%)`; const actions = document.createElement('div'); actions.className = 'queue-item-actions'; const btn = document.createElement('button'); btn.className = 'btn'; btn.textContent = t('queue.cancel', 'Cancel'); btn.addEventListener('click', () => cancelJobById(j.id)); actions.appendChild(btn); row.appendChild(title); row.appendChild(actions); activeWrap.appendChild(row); }); } if (queueState.recent.length === 0) { const empty = document.createElement('div'); empty.className = 'queue-item-status'; empty.textContent = t('queue.none_finished', 'No finished tasks'); finishedWrap.appendChild(empty); } else { queueState.recent.forEach(j => { const row = document.createElement('div'); row.className = 'queue-item'; const title = document.createElement('div'); title.textContent = j.title || ''; const status = document.createElement('div'); status.className = 'queue-item-status'; status.textContent = j.status || ''; row.appendChild(title); row.appendChild(status); finishedWrap.appendChild(row); }); } updateSseStats(); }; const updateSseStats = () => { const wrap = qs('queueSseStats'); const stats = qs('queueSseStats')?.querySelector('.queue-stats'); if (!wrap || !stats) return; const enabled = window.DEBUG_TOOLS_ENABLED === true; wrap.classList.toggle('is-hidden', !enabled); if (!enabled) return; const sse = window.UI.getSseStats(); const lastAt = sse.last > 0 ? new Date(sse.last).toLocaleTimeString() : '—'; stats.textContent = `last: ${lastAt} | type: ${sse.lastType} | reconnects: ${sse.reconnects}`; }; const apiJson = async (path, opts = {}) => { if (window.Api?.request) { const res = await window.Api.request(path, opts); return res?.data || res || null; } const token = window.Auth?.getAccessToken?.(); const headers = { ...(opts.headers || {}) }; if (token) headers.Authorization = `Bearer ${token}`; const res = await fetch(path, { ...opts, headers }); const json = await res.json().catch(() => null); return json?.data || null; }; const loadRecent = async () => { // disabled: SSE-only mode }; summary.addEventListener('click', () => { menu.classList.toggle('is-hidden'); if (!menu.classList.contains('is-hidden')) { renderQueueMenu(); } }); document.addEventListener('click', (e) => { if (summary.contains(e.target) || menu.contains(e.target)) return; menu.classList.add('is-hidden'); }); updateQueueSummary({ running: 0, errors: 0 }); updateSseStats(); if (window.Sse?.on) { window.Sse.on('jobs', (payload) => { if (!payload) return; updateQueueSummary(payload); updateJobIndicator(payload.active || null); queueState.active = Array.isArray(payload.active_list) ? payload.active_list : []; if (isMenuOpen()) renderQueueMenu(); }); window.Sse.on('tick', () => updateSseStats()); } }; window.UI.initHeader = function () { if (window.Auth?.initHeaderControls) { window.Auth.initHeaderControls(); } if (window.Auth?.initSessionRefresh) { window.Auth.initSessionRefresh(); } window.UI.initThemeToggle(); window.UI.startSseIndicatorPolling(); window.UI.initQueuePanel(); window.UI.startSseClient(); const menu = document.querySelector('.topbar-menu'); if (menu && menu.dataset.outsideClose !== '1') { menu.dataset.outsideClose = '1'; document.addEventListener('click', (e) => { if (!menu.hasAttribute('open')) return; const target = e.target; if (target && menu.contains(target)) return; menu.removeAttribute('open'); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && menu.hasAttribute('open')) { menu.removeAttribute('open'); } }); } if (window.Sse?.on && window.UI?.blinkSseIndicator) { const blink = () => window.UI.blinkSseIndicator(); window.Sse.on('message', blink); window.Sse.on('jobs', blink); window.Sse.on('sources', blink); window.Sse.on('tick', blink); } }; })();