diff --git a/containers/libreportal/frontend/components/admin/core/css/admin.css b/containers/libreportal/frontend/components/admin/core/css/admin.css
index d981645..b34e56c 100644
--- a/containers/libreportal/frontend/components/admin/core/css/admin.css
+++ b/containers/libreportal/frontend/components/admin/core/css/admin.css
@@ -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;
diff --git a/containers/libreportal/frontend/components/admin/overview/js/overview-page.js b/containers/libreportal/frontend/components/admin/overview/js/overview-page.js
index 60c5589..c59901b 100644
--- a/containers/libreportal/frontend/components/admin/overview/js/overview-page.js
+++ b/containers/libreportal/frontend/components/admin/overview/js/overview-page.js
@@ -14,7 +14,7 @@ class OverviewPage {
async init() {
const r = this.root();
- if (r) r.innerHTML = '
';
+ if (r) r.innerHTML = '' + lpLoadingBox() + '
';
this.bindEvents();
const [upd, verify, backup, ssh, disk, mem, info] = await Promise.all([
this.fetchJson('/data/system/update_status.json'),
diff --git a/containers/libreportal/frontend/components/admin/ssh/js/ssh-page.js b/containers/libreportal/frontend/components/admin/ssh/js/ssh-page.js
index 9a97aa6..561e723 100644
--- a/containers/libreportal/frontend/components/admin/ssh/js/ssh-page.js
+++ b/containers/libreportal/frontend/components/admin/ssh/js/ssh-page.js
@@ -17,7 +17,7 @@ class SshPage {
async init() {
const r = this.root();
- if (r) r.innerHTML = '';
+ if (r) r.innerHTML = '' + lpLoadingBox() + '
';
this.bindEvents();
await this.refresh();
this.render();
diff --git a/containers/libreportal/frontend/components/admin/system/js/system-metric-page.js b/containers/libreportal/frontend/components/admin/system/js/system-metric-page.js
index 75f2d1b..c3d67db 100644
--- a/containers/libreportal/frontend/components/admin/system/js/system-metric-page.js
+++ b/containers/libreportal/frontend/components/admin/system/js/system-metric-page.js
@@ -195,7 +195,7 @@ class SystemMetricPage {
No samples in this range yet — check back in a minute.
- Loading history…
+ Loading history…
diff --git a/containers/libreportal/frontend/components/admin/system/js/system-page.js b/containers/libreportal/frontend/components/admin/system/js/system-page.js
index 6436c25..bcd7dfe 100644
--- a/containers/libreportal/frontend/components/admin/system/js/system-page.js
+++ b/containers/libreportal/frontend/components/admin/system/js/system-page.js
@@ -84,7 +84,7 @@ class SystemPage {
}
// Default: index view.
- r.innerHTML = '';
+ r.innerHTML = '' + lpLoadingBox('Loading system stats…') + '
';
await this.refresh();
this.bind();
if (window.LiveSystem) {
diff --git a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js
index c057687..c223b02 100755
--- a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js
+++ b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js
@@ -586,7 +586,7 @@ class AppTabbedManager {
});
}
- section.innerHTML = 'Loading…
';
+ 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
diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
index c09a543..31f9084 100644
--- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
+++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
@@ -235,7 +235,7 @@ class OverviewManager {
try { window.migratePage.refreshAll().then(() => window.migratePage.render()).catch(() => {}); } catch (_) {}
return;
}
- panel.innerHTML = 'Loading…
';
+ 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 = 'Loading…
';
+ 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 = 'Loading backup center…
';
+ pane.innerHTML = lpLoadingBox('Loading backup center…');
this._loadBackupCenter(pane, sub);
}
diff --git a/containers/libreportal/frontend/components/backup/configuration/js/backup-engine-details.js b/containers/libreportal/frontend/components/backup/configuration/js/backup-engine-details.js
index bc60b75..8caa814 100644
--- a/containers/libreportal/frontend/components/backup/configuration/js/backup-engine-details.js
+++ b/containers/libreportal/frontend/components/backup/configuration/js/backup-engine-details.js
@@ -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 = `Loading engine details…
`;
+ 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()}`);
diff --git a/containers/libreportal/frontend/core/loading/css/loading-screen.css b/containers/libreportal/frontend/core/loading/css/loading-screen.css
index 1cb5ab6..8fcbb1f 100755
--- a/containers/libreportal/frontend/core/loading/css/loading-screen.css
+++ b/containers/libreportal/frontend/core/loading/css/loading-screen.css
@@ -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;
+}
diff --git a/containers/libreportal/frontend/core/loading/js/loading-ui.js b/containers/libreportal/frontend/core/loading/js/loading-ui.js
index e4fdaba..168fa2f 100755
--- a/containers/libreportal/frontend/core/loading/js/loading-ui.js
+++ b/containers/libreportal/frontend/core/loading/js/loading-ui.js
@@ -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 ``
+ + `
`
+ + `
${message}
`
+ + `
`;
+};