2026-01-17 00:55:52 +01:00

361 lines
12 KiB
JavaScript

// public/assets/ui.js
/* English comments: shared DOM + UI helpers */
(function () {
const THEME_COOKIE = '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 getCookie(name) {
const parts = document.cookie.split(';').map((p) => p.trim());
for (const part of parts) {
if (!part) continue;
const idx = part.indexOf('=');
if (idx === -1) continue;
const key = part.slice(0, idx);
if (key === name) return decodeURIComponent(part.slice(idx + 1));
}
return '';
}
function setCookie(name, value, days = 365) {
const expires = new Date(Date.now() + days * 86400000).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; expires=${expires}; SameSite=Lax`;
}
function setTheme(theme) {
const mode = theme === 'light' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', mode);
setCookie(THEME_COOKIE, mode);
}
function initThemeToggle() {
const btn = qs('themeToggle');
const saved = getCookie(THEME_COOKIE) || '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);
}
};
})();