Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
61334d1e72 Merge claude/1 2026-06-25 22:06:03 +01:00
librelad
ab0822c46b feat(webui/loading): shared boxed spinner loader for page/panel loading states
Pages and panels showed inconsistent loading states: the Backup center
and several admin pages (System, SSH, admin Overview), the Overview
Migrate/Peers panels, the per-app updater section and the backup engine
details modal rendered a bare 'Loading…' text line (updater-empty /
backup-empty-state) with no spinner, while Services/Config/Tasks used a
boxed card + spinner.

Add one shared loader — window.lpLoadingBox(message) + .lp-loading CSS in
the core/loading subsystem (the boxed card + accent spinner the good tabs
already use) — and route those bare-text loaders through it. The system
metric graph keeps its absolute overlay but gains the same spinner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-25 22:06:03 +01:00
10 changed files with 73 additions and 9 deletions

View File

@ -654,6 +654,7 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
.sys-detail-empty {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
gap: 8px;
color: rgba(var(--text-rgb), 0.45);
font-size: 0.92rem;
pointer-events: none;

View File

@ -14,7 +14,7 @@ class OverviewPage {
async init() {
const r = this.root();
if (r) r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading…</div></div>';
if (r) r.innerHTML = '<div class="admin-page">' + lpLoadingBox() + '</div>';
this.bindEvents();
const [upd, verify, backup, ssh, disk, mem, info] = await Promise.all([
this.fetchJson('/data/system/update_status.json'),

View File

@ -17,7 +17,7 @@ class SshPage {
async init() {
const r = this.root();
if (r) r.innerHTML = '<div class="ssh-page"><div class="backup-empty-state">Loading…</div></div>';
if (r) r.innerHTML = '<div class="ssh-page">' + lpLoadingBox() + '</div>';
this.bindEvents();
await this.refresh();
this.render();

View File

@ -195,7 +195,7 @@ class SystemMetricPage {
</section>
<section class="sys-metric-canvas">
<div class="sys-detail-empty" hidden>No samples in this range yet check back in a minute.</div>
<div class="sys-detail-loading">Loading history</div>
<div class="sys-detail-loading"><span class="lp-loading-spinner" aria-hidden="true"></span><span>Loading history</span></div>
<svg class="sys-detail-svg" preserveAspectRatio="none" aria-hidden="true"></svg>
<div class="sys-detail-tooltip" hidden></div>
</section>

View File

@ -84,7 +84,7 @@ class SystemPage {
}
// Default: index view.
r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading system stats…</div></div>';
r.innerHTML = '<div class="admin-page">' + lpLoadingBox('Loading system stats…') + '</div>';
await this.refresh();
this.bind();
if (window.LiveSystem) {

View File

@ -586,7 +586,7 @@ class AppTabbedManager {
});
}
section.innerHTML = '<div class="updater-empty">Loading…</div>';
section.innerHTML = lpLoadingBox();
await this.appUpdater.refreshAll();
const app = (this.appUpdater.apps || []).find((x) => x.name === this.currentApp);
// Always lead with the title block + recessed dark container, so the tab

View File

@ -235,7 +235,7 @@ class OverviewManager {
try { window.migratePage.refreshAll().then(() => window.migratePage.render()).catch(() => {}); } catch (_) {}
return;
}
panel.innerHTML = '<div class="updater-empty">Loading…</div>';
panel.innerHTML = lpLoadingBox();
try {
if (typeof MigratePage === 'undefined' && window.spaClean && window.spaClean.loadScript) {
await window.spaClean.loadScript('/components/apps/overview/migrate/js/migrate-page.js');
@ -259,7 +259,7 @@ class OverviewManager {
try { window.peersPage.refreshAll().then(() => window.peersPage.render()).catch(() => {}); } catch (_) {}
return;
}
panel.innerHTML = '<div class="updater-empty">Loading…</div>';
panel.innerHTML = lpLoadingBox();
try {
if (typeof PeersPage === 'undefined' && window.spaClean && window.spaClean.loadScript) {
await window.spaClean.loadScript('/components/admin/peers/js/peers-page.js');
@ -616,7 +616,7 @@ class OverviewManager {
} catch (_) {}
return;
}
pane.innerHTML = '<div class="updater-empty">Loading backup center…</div>';
pane.innerHTML = lpLoadingBox('Loading backup center…');
this._loadBackupCenter(pane, sub);
}

View File

@ -42,7 +42,7 @@ Object.assign(BackupPage.prototype, {
const row = triggerEl?.closest('.backup-engine-input-row');
const sel = row?.querySelector('select, input');
if (sel && sel.value) engineId = sel.value.trim();
body.innerHTML = `<div class="backup-empty-state">Loading engine details…</div>`;
body.innerHTML = lpLoadingBox('Loading engine details…');
modal.classList.add('open');
const data = await this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(engineId)}.json?t=${Date.now()}`);

View File

@ -539,3 +539,52 @@
.error-list-container li {
margin-bottom: 8px !important;
}
/* ============================================================
Shared in-page section loader (window.lpLoadingBox()).
One consistent "boxed card + spinner + text" used everywhere a
page or panel is fetching data, so every loading state matches
instead of some showing a bare text line with no spinner. Mirrors
the boxed loading card the Services/Config/Tasks tabs already use.
`@keyframes spin` is defined globally in core/theme/css/base.css.
============================================================ */
.lp-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.7rem;
padding: 40px 20px;
min-height: 180px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(var(--text-rgb), 0.1);
border-radius: 8px;
color: var(--text-secondary, var(--text-muted));
font-size: 0.9rem;
}
.lp-loading-spinner {
width: 22px;
height: 22px;
border: 2px solid rgba(var(--text-rgb), 0.15);
border-top-color: var(--accent-color, var(--accent));
border-radius: 50%;
animation: spin 1s linear infinite;
}
.lp-loading-text {
line-height: 1.35;
text-align: center;
}
/* Compact variant for small inline overlays (e.g. metric graphs) no
box, just a centered spinner + text laid out in a row. */
.lp-loading.lp-loading-inline {
flex-direction: row;
gap: 8px;
min-height: 0;
padding: 0;
background: none;
border: 0;
}

View File

@ -398,3 +398,17 @@ class LoadingUI {
this.systemCards.clear();
}
}
// Shared in-page section loader markup. Returns the HTML for one consistent
// "boxed card + spinner + text" so every page/panel that's fetching data
// shows the same loading state (see core/loading/css/loading-screen.css).
// el.innerHTML = lpLoadingBox(); // default "Loading…"
// el.innerHTML = lpLoadingBox('Loading apps…'); // custom message
// el.innerHTML = lpLoadingBox('Loading…', { inline: true }); // compact, no box
window.lpLoadingBox = function (message = 'Loading…', opts = {}) {
const cls = `lp-loading${opts.inline ? ' lp-loading-inline' : ''}${opts.className ? ` ${opts.className}` : ''}`;
return `<div class="${cls}" role="status" aria-live="polite">`
+ `<div class="lp-loading-spinner" aria-hidden="true"></div>`
+ `<div class="lp-loading-text">${message}</div>`
+ `</div>`;
};