From d8f585aada0bee531cadcd8e1cc7c3931f5c503c Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 28 May 2026 02:00:05 +0100 Subject: [PATCH] ux(backup): global Backups tab matches the per-app card pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /backup → Backups tab was the last surface still rendering snapshots as a plain HTML table — every other backup-related list had moved to the .task-item card pattern shared with Services. Cohesion-only refactor: both surfaces now look identical, with the global view adding the fields the per-app view doesn't need. HTML: drops + its , replaces with a single
that the same .backup-snapshot-flash deep-link highlight already targets. renderSnapshots() now emits .task-item cards via the new _renderSnapshotRow() helper. Each card carries: app icon · "12h ago" title · app-name chip (linked) · location pill · timestamp chip · short-ID chip Restore · Delete · Details Extras vs the per-app card: - App-name chip — global list isn't scoped to one app, so each row needs to name the app it belongs to. The chip is the deep-link to /app//backups?snapshot= (replaces the dashed-underline "link" treatment on the old App / ID table cells). - Delete button alongside Restore — destructive cleanup lives on the global view, not on the per-app card. - "System config" rows (snapshots without an app= tag) get the LibrePortal icon and no app-link (no per-app page to open). Detail panel (expanded via header / Details button) shows App, Backup ID, Location, full timestamp, Host, Tags, Paths — the same shape as the per-app version, plus Host (relevant on the global multi-host view). Click delegation: - [data-action="toggle-snapshot-row"] on the header + Details button toggles .task-details-open - Restore / Delete buttons now stopPropagation so clicking them doesn't also toggle the panel - Existing [data-deep-link] handler is reused by the app-name chip Signed-off-by: librelad --- .../frontend/html/backup-content.html | 19 +-- .../js/components/backup/backup-page.js | 121 +++++++++++++----- 2 files changed, 93 insertions(+), 47 deletions(-) diff --git a/containers/libreportal/frontend/html/backup-content.html b/containers/libreportal/frontend/html/backup-content.html index d1679a0..fc9e6ec 100644 --- a/containers/libreportal/frontend/html/backup-content.html +++ b/containers/libreportal/frontend/html/backup-content.html @@ -111,21 +111,10 @@
-
-
- - - - - - - - - - - -
AppHostLocationWhenIDActions
- + +
diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js index 371d996..ca64b7f 100644 --- a/containers/libreportal/frontend/js/components/backup/backup-page.js +++ b/containers/libreportal/frontend/js/components/backup/backup-page.js @@ -234,16 +234,28 @@ class BackupPage { const restoreBtn = e.target.closest('[data-action="restore-snapshot"]'); if (restoreBtn) { + e.stopPropagation(); // don't also toggle the row's details panel this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot); return; } const deleteBtn = e.target.closest('[data-action="delete-snapshot"]'); if (deleteBtn) { + e.stopPropagation(); this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot); return; } + // Row header / Details button → toggle the .task-details panel. + // Matches the per-app Backups tab interaction. + const snapToggle = e.target.closest('[data-action="toggle-snapshot-row"]'); + if (snapToggle) { + const item = snapToggle.closest('.backup-snapshot-item'); + const details = item && item.querySelector('.task-details'); + if (details) details.classList.toggle('task-details-open'); + return; + } + const locEnable = e.target.closest('[data-action="toggle-location-enabled"]'); if (locEnable) { const cb = locEnable.querySelector('input[type="checkbox"]'); @@ -1045,8 +1057,8 @@ class BackupPage { } renderSnapshots() { - const tbody = document.getElementById('backup-snapshot-tbody'); - if (!tbody) return; + const list = document.getElementById('backup-snapshot-list'); + if (!list) return; const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase(); const locFilter = document.getElementById('backup-snapshot-repo')?.value || ''; @@ -1067,6 +1079,8 @@ class BackupPage { locName: locNameByIdx[locIdx] || `Loc ${locIdx}`, time: s.time, id: s.short_id || (s.id || '').slice(0, 8), + tags: Array.isArray(s.tags) ? s.tags : [], + paths: Array.isArray(s.paths) ? s.paths : [], }); }); }); @@ -1081,39 +1095,82 @@ class BackupPage { ) : rows; if (!filtered.length) { - tbody.innerHTML = `No backups yet.`; + list.innerHTML = `
No backups yet.
`; return; } - tbody.innerHTML = filtered.map(r => { - // Link the App and ID cells to the per-app Backups tab with the - // snapshot pre-expanded. "—" rows (snapshots without an - // app= tag, e.g. system config backups) stay plain text - // since there's no app page to open. - const hasApp = r.app && r.app !== '—'; - const deepLink = hasApp - ? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}` - : null; - const appCell = hasApp - ? `${this.escape(r.app)}` - : this.escape(r.app); - const idCell = hasApp - ? `${this.escape(r.id)}` - : `${this.escape(r.id)}`; - return ` - - ${appCell} - ${this.escape(r.host)} - ${this.escape(r.locName)} - ${this.formatRelative(r.time)} - ${idCell} - - - - - - `; - }).join(''); + list.innerHTML = filtered.map(r => this._renderSnapshotRow(r)).join(''); + } + + // Render one global-Snapshots-tab backup as the same .task-item card + // the per-app Backups tab uses, so the two surfaces look identical. + // Extras vs the per-app card: + // - An app-name chip (because the global list isn't scoped to one app) + // that doubles as a deep-link to /app//backups?snapshot= + // - A Delete action alongside Restore (per-app card only offers + // Restore — delete lives in the global view) + _renderSnapshotRow(r) { + const hasApp = r.app && r.app !== '—'; + const deepLink = hasApp + ? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}` + : null; + const iconUrl = hasApp ? `/icons/apps/${encodeURIComponent(r.app)}.svg` : '/icons/apps/libreportal.svg'; + const displayName = hasApp ? this.appMeta(r.app).displayName : 'System config'; + const appChip = hasApp + ? `${this.escape(displayName)}` + : `${this.escape(displayName)}`; + const sid = String(r.id); + return ` +
+
+
+ + ${this.escape(this.formatRelative(r.time))} + ${appChip} + ${this.escape(r.locName)} + ${this.escape(this._fmtShortTime(r.time))} + ${this.escape(sid)} +
+
+ + + +
+
+
+
+
App: ${this.escape(displayName)}${hasApp ? ` (${this.escape(r.app)})` : ''}
+
Backup ID: ${this.escape(sid)}
+
Location: ${this.escape(r.locName)}
+
When: ${this.escape(this._fmtFullTime(r.time))}
+
Host: ${this.escape(r.host)}
+ ${r.tags && r.tags.length ? `
Tags: ${r.tags.map(t => `${this.escape(t)}`).join(' ')}
` : ''} + ${r.paths && r.paths.length ? `
Paths:
${r.paths.map(p => this.escape(p)).join('
')}
` : ''} +
+
+
`; + } + + _fmtShortTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return String(iso); + return d.toLocaleString(); + } + _fmtFullTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return String(iso); + return d.toString(); } renderConfiguration() {