feat(webui): green Upgrade, trailing picker, select-all, icon in confirms
Four fixes to the Updates tab, all reported from the same screenshot. Upgrade is green and just says "Upgrade". It was accent-styled and carried the target version, which duplicated the confirmation it opens — the dialog already names the version, so the button only had to name the action. Green also stops it reading as the same control as the cyan Update beside it. The picker moved from the head of the row to the tail, matching the Tasks list. It used to lead with an `ov-pick-empty` spacer holding the column open, so with nothing updatable every row carried a left gutter for a checkbox that was never coming — the gap in the report. Unselectable rows now render no picker at all. Select all joins the toolbar, right-aligned above that column, again mirroring Tasks. It acts on the selectable rows in view, so a narrowing filter chip cannot leave it quietly picking rows nobody can see. The confirmation dialog takes an optional icon and the upgrade flow passes the app's, so it leads with the same icon-in-holder the instance modals use. Styled in base.css rather than borrowing .app-card-icon: this dialog is global and appears on pages that never load the apps stylesheet. Its title now uses the display name too — "Upgrade matrix to…" beside the Matrix logo read as a different thing. Verified against the live WebUI: green Upgrade with the gutter gone; Select all and the trailing checkboxes with two apps temporarily flagged (data restored byte-identical after); the master toggling 0 -> 2 -> 0; and the dialog showing matrix.svg under the title "Upgrade Matrix to v1.159.0?".
This commit is contained in:
parent
325f8f3be8
commit
2510b1a52f
@ -194,6 +194,38 @@
|
||||
padding: 0 6px;
|
||||
}
|
||||
.ov-toolbar-actions { display: flex; gap: 8px; }
|
||||
/* Select-all, mirroring the Tasks toolbar: pushed to the right of the chips so
|
||||
the picker column and its master control line up on the same edge. */
|
||||
.ov-select-all {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ov-select-all input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Trailing picker. No fixed width reserved: an unselectable row renders no
|
||||
checkbox at all, so holding a column open would reintroduce the gutter this
|
||||
replaced. */
|
||||
.ov-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.ov-pick-box {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---- Updates: expandable rows ------------------------------------------- */
|
||||
/* Block (not the inherited .updater-row 3-col grid) so the head sits on top and
|
||||
|
||||
@ -331,6 +331,21 @@ class OverviewManager {
|
||||
this.selected.clear();
|
||||
this._applyTab('updates');
|
||||
return;
|
||||
case 'select-all': {
|
||||
// Acts on what is actually selectable and in view — the filter chips
|
||||
// can narrow the list, and "select all" that quietly picked rows the
|
||||
// user cannot see would be a worse answer than none.
|
||||
const boxes = Array.from(document.querySelectorAll('#overview-view .ov-pick-box'));
|
||||
const on = !!(oa.checked);
|
||||
for (const b of boxes) {
|
||||
b.checked = on;
|
||||
const slug = b.dataset.app;
|
||||
if (!slug) continue;
|
||||
if (on) this.selected.add(slug); else this.selected.delete(slug);
|
||||
}
|
||||
this._syncSelectionBar();
|
||||
break;
|
||||
}
|
||||
case 'update-selected':
|
||||
if (this.updater) this.updater.applySelected([...this.selected]);
|
||||
this.selected.clear();
|
||||
@ -606,6 +621,15 @@ class OverviewManager {
|
||||
return [...this.selected];
|
||||
}
|
||||
|
||||
// True when every selectable row currently in view is picked — drives the
|
||||
// master checkbox's checked state on render.
|
||||
_allPicked() {
|
||||
const up = this.updater;
|
||||
if (!up) return false;
|
||||
const avail = up.apps.filter((a) => a.update_available).map((a) => a.name);
|
||||
return avail.length > 0 && avail.every((n) => this.selected.has(n));
|
||||
}
|
||||
|
||||
// Update the bar in place. Re-rendering the whole tab on every tick would
|
||||
// rebuild the checkboxes underneath the pointer and lose focus mid-selection.
|
||||
_syncSelectionBar() {
|
||||
@ -655,6 +679,10 @@ class OverviewManager {
|
||||
${up.renderAutoCheckLine()}
|
||||
<div class="updater-toolbar ov-toolbar">
|
||||
<div class="ov-chips">${chip('all', 'All', up.apps.length)}${chip('updates', 'Updates', nUpd)}${chip('newer', 'Newer', nNew)}${chip('security', 'Security', nSec)}</div>
|
||||
${nUpd ? `<label class="ov-select-all" title="Select every updatable app in view">
|
||||
<input type="checkbox" data-overview-action="select-all"${this._allPicked() ? ' checked' : ''}>
|
||||
<span>Select all</span>
|
||||
</label>` : ''}
|
||||
</div>
|
||||
${nUpd ? this.renderSelectionBar() : ''}
|
||||
<div class="updater-list ov-updates-list">${rows}</div>`;
|
||||
@ -765,20 +793,25 @@ class OverviewManager {
|
||||
const updBtn = a.update_available
|
||||
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${slug}">Update</button>`
|
||||
: (a.newer_version
|
||||
? `<button class="updater-btn ov-upgrade-btn" data-updater-action="upgrade" data-app="${slug}" data-version="${esc(a.newer_version)}" title="Move to the ${esc(a.newer_version)} release line — you will be asked to confirm first">Upgrade to ${esc(a.newer_version)}</button>`
|
||||
? `<button class="updater-btn updater-btn-success" data-updater-action="upgrade" data-app="${slug}" data-version="${esc(a.newer_version)}" title="Move to the ${esc(a.newer_version)} release line — you will be asked to confirm first">Upgrade</button>`
|
||||
: '');
|
||||
// Only rows with something to apply are selectable. A checkbox on a row
|
||||
// that is already current would offer a choice with no outcome, and
|
||||
// "selected 6, updated 2" is a worse answer than not offering the 4.
|
||||
// Trailing, and absent rather than blank when the row is not selectable.
|
||||
// It used to lead the row with an `ov-pick-empty` spacer holding the column
|
||||
// open — so with nothing updatable, every row carried a left gutter for a
|
||||
// checkbox that was never coming. Matches the Tasks list, where the picker
|
||||
// sits last and unselectable rows simply have none.
|
||||
const pick = a.update_available
|
||||
? `<span class="ov-pick"><input type="checkbox" class="ov-pick-box" data-overview-action="select" data-app="${slug}"${this.selected.has(a.name) ? ' checked' : ''} aria-label="Select ${name} for update"></span>`
|
||||
: '<span class="ov-pick ov-pick-empty" aria-hidden="true"></span>';
|
||||
: '';
|
||||
return `<div class="updater-row ov-row" data-app="${slug}">
|
||||
<div class="updater-row-head ov-row-head" data-overview-action="toggle" data-app="${slug}" role="button" tabindex="0" aria-expanded="false" aria-controls="ov-detail-${slug}">
|
||||
${pick}<img class="ov-row-icon" src="/core/icons/apps/${slug}.svg" alt="" onerror="this.style.display='none'">
|
||||
<img class="ov-row-icon" src="/core/icons/apps/${slug}.svg" alt="" onerror="this.style.display='none'">
|
||||
<span class="updater-row-name">${name}</span> ${status} ${sev} ${pol} ${newer} ${stale}
|
||||
<span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span>
|
||||
<span class="ov-row-actions">${updBtn}<button class="ov-details-btn" data-overview-action="toggle" data-app="${slug}" tabindex="-1" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg><span>Details</span></button></span>
|
||||
<span class="ov-row-actions">${updBtn}<button class="ov-details-btn" data-overview-action="toggle" data-app="${slug}" tabindex="-1" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg><span>Details</span></button>${pick}</span>
|
||||
</div>
|
||||
<div class="updater-row-details ov-row-details" id="ov-detail-${slug}" role="region" aria-label="${name} details" hidden></div>
|
||||
</div>`;
|
||||
|
||||
@ -184,6 +184,17 @@
|
||||
color: rgb(var(--page-rgb, var(--accent-rgb)));
|
||||
}
|
||||
.updater-btn-primary:hover { background: rgba(var(--page-rgb, var(--accent-rgb)), 0.3); }
|
||||
/* Green, for moving to a newer release line. Distinct from the primary (accent)
|
||||
Update so the two actions on a row never read as the same thing.
|
||||
Pastel #86efac text on a saturated green rather than --status-success itself,
|
||||
matching .app-tag.installed-tag: the raw #28a745 is too dark to read at button
|
||||
size on dark themes. */
|
||||
.updater-btn-success {
|
||||
background: rgba(var(--status-success-rgb), 0.30);
|
||||
border-color: rgba(var(--status-success-rgb), 0.65);
|
||||
color: #86efac;
|
||||
}
|
||||
.updater-btn-success:hover { background: rgba(var(--status-success-rgb), 0.45); }
|
||||
|
||||
/* ---- Hints / empty states ---- */
|
||||
.updater-hint { padding: 12px 15px; border-radius: 11px; margin-bottom: 14px; font-size: 0.84rem;
|
||||
|
||||
@ -339,12 +339,15 @@ class UpdaterPage {
|
||||
const a = this.apps.find((x) => x.name === app) || {};
|
||||
const from = a.channel || a.current_version || 'the current version';
|
||||
const to = version || a.newer_version || 'the newest release';
|
||||
// Display name, not the slug: the dialog now leads with the app's icon, and
|
||||
// "Upgrade matrix to…" beside the Matrix logo reads as a different thing.
|
||||
const label = (window.getAppDisplayName ? window.getAppDisplayName(app) : null) || a.displayName || app;
|
||||
const body = `
|
||||
<div class="updater-detail-section">
|
||||
<p><strong>${this.escape(app)}</strong> will move from <strong>${this.escape(from)}</strong>
|
||||
<p><strong>${this.escape(label)}</strong> will move from <strong>${this.escape(from)}</strong>
|
||||
to <strong>${this.escape(to)}</strong>, one release at a time.</p>
|
||||
<p class="updater-detail-meta">Every step takes its own recovery snapshot first, then waits for
|
||||
${this.escape(app)} to confirm it is serving that version with no migration outstanding.
|
||||
${this.escape(label)} to confirm it is serving that version with no migration outstanding.
|
||||
If any step fails it is rolled back and the upgrade stops there, leaving the app on the last
|
||||
version that verified.</p>
|
||||
<p class="updater-detail-meta">This can take a long time — each release runs its own database
|
||||
@ -353,8 +356,9 @@ class UpdaterPage {
|
||||
const go = () => this.dispatch('updater_upgrade', { app, version: version || '' },
|
||||
`Upgrading ${app} to ${to}, one release at a time…`);
|
||||
if (window.showConfirmation) {
|
||||
window.showConfirmation(`Upgrade ${app} to ${to}?`, '', go, 'Start upgrade', 'Cancel', 'warning', false, '', body);
|
||||
} else if (window.confirm(`Upgrade ${app} from ${from} to ${to}, one release at a time?`)) {
|
||||
window.showConfirmation(`Upgrade ${label} to ${to}?`, '', go, 'Start upgrade', 'Cancel', 'warning', false, '', body,
|
||||
`/core/icons/apps/${app}.svg`);
|
||||
} else if (window.confirm(`Upgrade ${label} from ${from} to ${to}, one release at a time?`)) {
|
||||
go();
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,13 +31,14 @@ class ConfirmationDialog {
|
||||
|
||||
}
|
||||
|
||||
show(title, message, onConfirm, confirmText = 'Confirm', cancelText = 'Cancel', confirmClass = 'primary', showDataLossCheckbox = false, checkboxText = 'I understand I will lose all my data and it cannot be undone', messageHtml = '') {
|
||||
show(title, message, onConfirm, confirmText = 'Confirm', cancelText = 'Cancel', confirmClass = 'primary', showDataLossCheckbox = false, checkboxText = 'I understand I will lose all my data and it cannot be undone', messageHtml = '', iconUrl = '') {
|
||||
|
||||
this.callback = onConfirm;
|
||||
|
||||
// Build dialog content
|
||||
this.dialog.innerHTML = `
|
||||
<div class="confirmation-header">
|
||||
${iconUrl ? `<div class="confirmation-app-icon"><img src="${this.escapeHtml(iconUrl)}" alt="" onerror="this.onerror=null; this.src='/core/icons/apps/default.svg'"></div>` : ''}
|
||||
<h3>${this.escapeHtml(title)}</h3>
|
||||
<button class="confirmation-close" onclick="window.confirmationDialog.hide()">×</button>
|
||||
</div>
|
||||
@ -142,12 +143,12 @@ function initConfirmationDialog() {
|
||||
// initConfirmationDialog() will be called centrally
|
||||
|
||||
// Global function
|
||||
window.showConfirmation = (title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText, messageHtml) => {
|
||||
window.showConfirmation = (title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText, messageHtml, iconUrl) => {
|
||||
// Ensure dialog is initialized
|
||||
initConfirmationDialog();
|
||||
|
||||
if (confirmationDialog) {
|
||||
confirmationDialog.show(title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText, messageHtml);
|
||||
confirmationDialog.show(title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText, messageHtml, iconUrl);
|
||||
} else {
|
||||
// Fallback to native confirm
|
||||
if (confirm(message)) {
|
||||
|
||||
@ -901,8 +901,33 @@ html[data-theme="nebula"]::after {
|
||||
border-bottom: 1px solid var(--border-color, #444);
|
||||
}
|
||||
|
||||
/* Icon holder, when a caller names an app. Styled here rather than reusing
|
||||
.app-card-icon from apps.css: this dialog is global and appears on pages that
|
||||
never load the apps stylesheet, where borrowing that class would render an
|
||||
unstyled image. */
|
||||
.confirmation-app-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
padding: 7px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--text-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.2);
|
||||
}
|
||||
.confirmation-app-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.confirmation-header h3 {
|
||||
margin: 0;
|
||||
/* Takes the slack so the close button stays pinned right once an icon leads
|
||||
the header — the row is space-between, which would otherwise centre it. */
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 16px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user