361 lines
12 KiB
JavaScript
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);
|
|
}
|
|
};
|
|
})();
|