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>
This commit is contained in:
parent
9e1adef2b8
commit
ab0822c46b
@ -654,6 +654,7 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
|
|||||||
.sys-detail-empty {
|
.sys-detail-empty {
|
||||||
position: absolute; inset: 0;
|
position: absolute; inset: 0;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
color: rgba(var(--text-rgb), 0.45);
|
color: rgba(var(--text-rgb), 0.45);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@ -14,7 +14,7 @@ class OverviewPage {
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const r = this.root();
|
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();
|
this.bindEvents();
|
||||||
const [upd, verify, backup, ssh, disk, mem, info] = await Promise.all([
|
const [upd, verify, backup, ssh, disk, mem, info] = await Promise.all([
|
||||||
this.fetchJson('/data/system/update_status.json'),
|
this.fetchJson('/data/system/update_status.json'),
|
||||||
|
|||||||
@ -17,7 +17,7 @@ class SshPage {
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const r = this.root();
|
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();
|
this.bindEvents();
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
this.render();
|
this.render();
|
||||||
|
|||||||
@ -195,7 +195,7 @@ class SystemMetricPage {
|
|||||||
</section>
|
</section>
|
||||||
<section class="sys-metric-canvas">
|
<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-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>
|
<svg class="sys-detail-svg" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||||||
<div class="sys-detail-tooltip" hidden></div>
|
<div class="sys-detail-tooltip" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -84,7 +84,7 @@ class SystemPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default: index view.
|
// 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();
|
await this.refresh();
|
||||||
this.bind();
|
this.bind();
|
||||||
if (window.LiveSystem) {
|
if (window.LiveSystem) {
|
||||||
|
|||||||
@ -586,7 +586,7 @@ class AppTabbedManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
section.innerHTML = '<div class="updater-empty">Loading…</div>';
|
section.innerHTML = lpLoadingBox();
|
||||||
await this.appUpdater.refreshAll();
|
await this.appUpdater.refreshAll();
|
||||||
const app = (this.appUpdater.apps || []).find((x) => x.name === this.currentApp);
|
const app = (this.appUpdater.apps || []).find((x) => x.name === this.currentApp);
|
||||||
// Always lead with the title block + recessed dark container, so the tab
|
// Always lead with the title block + recessed dark container, so the tab
|
||||||
|
|||||||
@ -235,7 +235,7 @@ class OverviewManager {
|
|||||||
try { window.migratePage.refreshAll().then(() => window.migratePage.render()).catch(() => {}); } catch (_) {}
|
try { window.migratePage.refreshAll().then(() => window.migratePage.render()).catch(() => {}); } catch (_) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
panel.innerHTML = '<div class="updater-empty">Loading…</div>';
|
panel.innerHTML = lpLoadingBox();
|
||||||
try {
|
try {
|
||||||
if (typeof MigratePage === 'undefined' && window.spaClean && window.spaClean.loadScript) {
|
if (typeof MigratePage === 'undefined' && window.spaClean && window.spaClean.loadScript) {
|
||||||
await window.spaClean.loadScript('/components/apps/overview/migrate/js/migrate-page.js');
|
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 (_) {}
|
try { window.peersPage.refreshAll().then(() => window.peersPage.render()).catch(() => {}); } catch (_) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
panel.innerHTML = '<div class="updater-empty">Loading…</div>';
|
panel.innerHTML = lpLoadingBox();
|
||||||
try {
|
try {
|
||||||
if (typeof PeersPage === 'undefined' && window.spaClean && window.spaClean.loadScript) {
|
if (typeof PeersPage === 'undefined' && window.spaClean && window.spaClean.loadScript) {
|
||||||
await window.spaClean.loadScript('/components/admin/peers/js/peers-page.js');
|
await window.spaClean.loadScript('/components/admin/peers/js/peers-page.js');
|
||||||
@ -616,7 +616,7 @@ class OverviewManager {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pane.innerHTML = '<div class="updater-empty">Loading backup center…</div>';
|
pane.innerHTML = lpLoadingBox('Loading backup center…');
|
||||||
this._loadBackupCenter(pane, sub);
|
this._loadBackupCenter(pane, sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ Object.assign(BackupPage.prototype, {
|
|||||||
const row = triggerEl?.closest('.backup-engine-input-row');
|
const row = triggerEl?.closest('.backup-engine-input-row');
|
||||||
const sel = row?.querySelector('select, input');
|
const sel = row?.querySelector('select, input');
|
||||||
if (sel && sel.value) engineId = sel.value.trim();
|
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');
|
modal.classList.add('open');
|
||||||
|
|
||||||
const data = await this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(engineId)}.json?t=${Date.now()}`);
|
const data = await this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(engineId)}.json?t=${Date.now()}`);
|
||||||
|
|||||||
@ -539,3 +539,52 @@
|
|||||||
.error-list-container li {
|
.error-list-container li {
|
||||||
margin-bottom: 8px !important;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -398,3 +398,17 @@ class LoadingUI {
|
|||||||
this.systemCards.clear();
|
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>`;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user