ui(backup): tile-click → Back-up checklist modal; LibrePortal icon on System tile; 2-up grid

Reshape the dashboard's Backup status grid into a click-to-pick UI:

- Removed the inline Back-up / Restore buttons from the System config
  tile. Same shape as an app tile now; LibrePortal app icon instead of
  the server-stack glyph.
- Grid is 2 columns (was auto-fill min 220px). Tiles are wider, read
  better, and the System tile no longer needs to span a full row to fit
  inline buttons.
- Click any tile (System or app) → opens a new "Back up" modal:
    * System config first (key=__system__, LibrePortal icon)
    * Every installed app, alphabetical
    * Checkbox per row + 'Select all' / 'Clear' shortcuts
    * The tile clicked is pre-ticked
- Confirm queues backup tasks:
    * Everything ticked  → single `libreportal backup all` (which also
      runs `backup system`) — one task instead of N
    * Subset            → one task per ticked item (`backup system`
      and/or `backup app create <slug>`)

Restore for System config used to live on the dashboard's inline
'Restore' button. It's now reachable via the Backups tab — system
snapshots appear in the snapshot list with the standard per-row
Restore action — same path apps already use. No new UI required;
just one fewer dashboard button.

Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-27 01:05:44 +01:00
parent c49007d6e0
commit 14bc0c3386
3 changed files with 147 additions and 30 deletions

View File

@ -205,7 +205,10 @@
.backup-app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
/* 2 per row clicking a tile opens the Back-up checklist modal, so we
no longer need room for inline action buttons. Wider tiles read
better and the System config tile fits one row alongside an app. */
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}

View File

@ -174,6 +174,20 @@
</div>
</div>
<div class="backup-modal" id="backup-pick-modal">
<div class="backup-modal-inner">
<div class="backup-modal-header">
<h3>Back up</h3>
<button class="backup-modal-close" data-close-modal>×</button>
</div>
<div class="backup-modal-body" id="backup-pick-modal-body"></div>
<div class="backup-modal-footer">
<button class="backup-secondary-btn" data-close-modal>Cancel</button>
<button class="backup-primary-btn" id="backup-pick-confirm">Back up selected</button>
</div>
</div>
</div>
<div class="backup-modal" id="backup-restore-modal">
<div class="backup-modal-inner">
<div class="backup-modal-header">

View File

@ -186,6 +186,24 @@ class BackupPage {
return;
}
// Click any tile on the dashboard → open the Back-up checklist
// modal with that tile pre-ticked. System tile is data-system="1";
// app tiles carry data-app="<slug>". Both share .backup-app-tile.
const tile = e.target.closest('.backup-app-tile');
if (tile) {
if (tile.dataset.system) {
this.openBackupPickModal({ preTickSystem: true });
} else if (tile.dataset.app) {
this.openBackupPickModal({ preTickApps: [tile.dataset.app] });
}
return;
}
if (e.target.closest('#backup-pick-confirm')) {
this.confirmBackupPick();
return;
}
const restoreBtn = e.target.closest('[data-action="restore-snapshot"]');
if (restoreBtn) {
this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot);
@ -614,46 +632,25 @@ class BackupPage {
}
}
// System config tile — same shape as an app tile but with a server icon
// and two inline action buttons (the standalone System config card used
// to host these). Rendered first in the Backup status grid so it always
// sits at eye-level.
// System config tile — same shape as an app tile but with the LibrePortal
// app icon. Clicking any tile (system or app) opens the Back-up checklist
// modal with that tile pre-ticked; there are no inline action buttons
// anymore. Rendered first in the Backup status grid so the bare-metal
// prerequisite is always visible up top.
renderSystemTile(sys) {
const has = !!sys.latest_snapshot;
const dot = has ? 'ok' : 'none';
const when = has ? 'Last backed up ' + this.formatRelative(sys.latest_time) : 'No backup yet';
return `
<div class="backup-app-tile backup-app-tile--system" data-system="1" style="grid-column: 1 / -1; display:flex; align-items:center; gap:14px">
<div class="backup-app-tile-icon" style="display:flex; align-items:center; justify-content:center; opacity:.8">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="6" rx="1"></rect>
<rect x="2" y="9" width="20" height="6" rx="1"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="12" x2="6.01" y2="12"></line>
</svg>
</div>
<div class="backup-app-tile-text" style="flex:1; min-width:0">
<div class="backup-app-tile backup-app-tile--system" data-system="1">
<img class="backup-app-tile-icon" src="/icons/apps/libreportal.svg" alt="" onerror="this.style.display='none'">
<div class="backup-app-tile-text">
<div class="backup-app-tile-name">System config</div>
<div class="backup-app-tile-meta">
<span class="backup-status-dot ${dot}"></span>
<span>${this.escape(when)}</span>
</div>
</div>
<div class="backup-system-actions" style="display:flex; gap:6px; flex-shrink:0">
<button type="button" class="backup-secondary-btn" data-action="backup-system" title="Snapshot the LibrePortal system config to every enabled location" style="padding:6px 10px; font-size:.85em">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>Back up
</button>
<button type="button" class="backup-secondary-btn" data-action="restore-system" title="Restore the latest system-config snapshot into a staging folder for review" style="padding:6px 10px; font-size:.85em">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>Restore
</button>
</div>
</div>
`;
}
@ -1833,6 +1830,109 @@ class BackupPage {
await this.runTask(`libreportal restore system`, 'restore', null);
}
/* ----- Back-up checklist modal -----
Triggered by clicking any tile in the Backup status grid. Lists System
config first (special row, key="__system__"), then every installed app.
Pre-ticks the tile that was clicked. Confirm queues one backup task
per ticked item except when EVERYTHING is ticked, in which case we
collapse to `libreportal backup all` (which also runs `backup system`
under the hood) so we only queue one task instead of N. */
openBackupPickModal(opts = {}) {
const modal = document.getElementById('backup-pick-modal');
const body = document.getElementById('backup-pick-modal-body');
if (!modal || !body) return;
const apps = this.dashboard?.apps || [];
const sys = this.dashboard?.system || {};
const preTickSystem = !!opts.preTickSystem;
const preTickApps = new Set(opts.preTickApps || []);
// System row at the top — uses the LibrePortal icon to match the
// dashboard tile. Then every installed app, alphabetical.
const sortedApps = apps.slice().sort((a, b) => a.app.localeCompare(b.app));
const row = (key, iconSrc, label, sub, checked) => `
<label class="backup-pick-row" style="display:flex; align-items:center; gap:12px; padding:10px 12px; border:1px solid rgba(var(--text-rgb),0.08); border-radius:8px; margin-bottom:6px; cursor:pointer">
<input type="checkbox" class="backup-pick-cb" value="${this.escape(key)}" ${checked ? 'checked' : ''}>
<img src="${this.escape(iconSrc)}" alt="" style="width:28px; height:28px; flex-shrink:0; border-radius:6px" onerror="this.style.display='none'">
<div style="flex:1; min-width:0">
<div style="font-weight:600">${this.escape(label)}</div>
<div class="backup-card-hint" style="font-size:.82em">${this.escape(sub)}</div>
</div>
</label>
`;
const sysSub = sys.latest_snapshot
? 'Last backed up ' + this.formatRelative(sys.latest_time)
: 'No backup yet';
const rows = [
row('__system__', '/icons/apps/libreportal.svg', 'System config', sysSub, preTickSystem),
...sortedApps.map(app => {
const meta = this.appMeta(app.app);
const sub = app.latest_snapshot
? 'Last backed up ' + this.formatRelative(app.latest_time)
: 'No backup yet';
return row(app.app, meta.icon, meta.displayName, sub, preTickApps.has(app.app));
})
].join('');
body.innerHTML = `
<p class="backup-card-hint" style="margin:0 0 10px">
Pick what to snapshot. Each selection runs in the background and shows up on the Tasks page.
</p>
<div style="display:flex; gap:14px; margin-bottom:10px; font-size:.85em">
<a href="#" data-pick-action="select-all" style="color:var(--accent); text-decoration:none">Select all</a>
<a href="#" data-pick-action="select-none" style="color:var(--accent); text-decoration:none">Clear</a>
</div>
<div class="backup-pick-list" style="max-height:60vh; overflow-y:auto">${rows}</div>
`;
// The select-all / clear links live inside the modal body so we wire
// them once here per open (they get rebuilt every open, no listener
// leak).
body.querySelectorAll('[data-pick-action]').forEach(a => {
a.addEventListener('click', (e) => {
e.preventDefault();
const all = a.dataset.pickAction === 'select-all';
body.querySelectorAll('.backup-pick-cb').forEach(cb => { cb.checked = all; });
});
});
modal.classList.add('open');
}
async confirmBackupPick() {
const modal = document.getElementById('backup-pick-modal');
if (!modal) return;
const selected = Array.from(modal.querySelectorAll('.backup-pick-cb:checked'))
.map(cb => cb.value);
if (!selected.length) {
this.notify('Pick at least one thing to back up.', 'error');
return;
}
this.closeAllModals();
const apps = this.dashboard?.apps || [];
const totalThings = apps.length + 1; // +1 for system
const wantsSystem = selected.includes('__system__');
const appSlugs = selected.filter(s => s !== '__system__');
// Whole-fleet shortcut — `backup all` queues a single task and also
// covers system, instead of N+1 separate tasks.
if (wantsSystem && appSlugs.length === apps.length) {
await this.runTask('libreportal backup all', 'backup', null);
return;
}
if (wantsSystem) {
await this.runTask('libreportal backup system', 'backup', null);
}
for (const slug of appSlugs) {
await this.runTask(`libreportal backup app create ${slug}`, 'backup', slug);
}
}
/* ----- Migrate (Phase 1: shared-backup) ----- */
renderMigrate() {