diff --git a/containers/libreportal/frontend/core/data-loader/js/data-loader.js b/containers/libreportal/frontend/core/data-loader/js/data-loader.js index 026e958..c8c8587 100755 --- a/containers/libreportal/frontend/core/data-loader/js/data-loader.js +++ b/containers/libreportal/frontend/core/data-loader/js/data-loader.js @@ -207,8 +207,9 @@ async function loadDashboardData() { // Start countdown to next automatic update startUpdateCountdown(); - // Show/refresh the "out of date" banner on the dashboard. - window.updateNotifier?.renderDashboardBanner(); + // Re-assert the network-attention banner now the dashboard DOM is in place + // (it leads the dashboard only when there's a real conflict to act on). + window.networkNotifier?.renderDashboardBanner(); } diff --git a/containers/libreportal/frontend/core/network-notifier/js/network-notifier.js b/containers/libreportal/frontend/core/network-notifier/js/network-notifier.js index 0320c5e..8b1b700 100644 --- a/containers/libreportal/frontend/core/network-notifier/js/network-notifier.js +++ b/containers/libreportal/frontend/core/network-notifier/js/network-notifier.js @@ -4,7 +4,7 @@ // IP no longer falls inside the shared network's real subnet (the "network // recreated with a different /24, app stranded" case). Two surfaces, shown ONLY // when there's a real conflict to act on: -// * a badge in the global topbar (after the update badge), and +// * a badge in the global topbar (after the update pill), and // * a banner on the dashboard. // Both are driven by /data/system/network_status.json, written host-side by // webuiSystemNetworkCheck (scripts/webui/data/generators/system/). @@ -108,9 +108,9 @@ class NetworkNotifier { badge.className = 'network-badge'; badge.type = 'button'; badge.addEventListener('click', () => this.openPanel()); - // Sit just after the update badge (if any) so the two coexist in a stable - // order — update first, network second — rather than racing for firstChild. - const anchor = document.getElementById('update-badge'); + // Sit just after the update pill so the two coexist in a stable order — + // update first, network second — rather than racing for firstChild. + const anchor = document.getElementById('update-pill'); if (anchor && anchor.parentNode === controls) controls.insertBefore(badge, anchor.nextSibling); else controls.insertBefore(badge, controls.firstChild); } @@ -145,10 +145,8 @@ class NetworkNotifier { if (!banner) { banner = document.createElement('div'); banner.id = 'network-banner'; - // After the update banner if present; else top of the dashboard. - const anchor = document.getElementById('update-banner'); - if (anchor && anchor.parentNode === main) main.insertBefore(banner, anchor.nextSibling); - else main.insertBefore(banner, main.firstChild); + // Attention-only, so it leads the dashboard when a conflict is present. + main.insertBefore(banner, main.firstChild); } const s = this.status; diff --git a/containers/libreportal/frontend/core/update-notifier/css/update-notifier.css b/containers/libreportal/frontend/core/update-notifier/css/update-notifier.css index 48d23e5..03501e8 100644 --- a/containers/libreportal/frontend/core/update-notifier/css/update-notifier.css +++ b/containers/libreportal/frontend/core/update-notifier/css/update-notifier.css @@ -2,97 +2,78 @@ Driven by js/update-notifier.js. Colours come from the active theme tokens (with safe fallbacks) so it tracks every palette. */ -/* ---- Topbar badge -------------------------------------------------------- */ +/* ---- Topbar pill (persistent update status) ------------------------------ */ -.update-badge { +.update-pill { display: inline-flex; align-items: center; gap: 6px; - padding: 7px 12px; - border: 1px solid var(--status-warning, #e0a106); - border-radius: 6px; - background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.14); - color: var(--status-warning, #e0a106); + padding: 6px 11px; + border: 1px solid transparent; + border-radius: 8px; font-weight: 600; - font-size: 0.85rem; + font-size: 0.82rem; cursor: pointer; - transition: background 0.2s, transform 0.1s; white-space: nowrap; + transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.1s; } -.update-badge:hover { +.update-pill svg { flex: 0 0 auto; } +.update-pill:active { transform: scale(0.97); } + +/* Calm states — up to date, local install, mid-check. Low emphasis: the + version/status sits quietly alongside the other topbar controls. */ +.update-pill.is-ok, +.update-pill.is-local, +.update-pill.is-checking { + color: var(--text-secondary, #b8c0cc); + background: var(--surface-bg, rgba(255, 255, 255, 0.04)); + border-color: var(--border-color, rgba(255, 255, 255, 0.1)); +} + +.update-pill.is-ok:hover, +.update-pill.is-local:hover, +.update-pill.is-checking:hover { + background: var(--surface-hover, rgba(255, 255, 255, 0.08)); + color: var(--text-primary, #fff); +} + +.update-pill.is-ok svg { color: var(--status-success, #2ea043); } +.update-pill.is-local svg, +.update-pill.is-checking svg { color: var(--text-muted, #8a93a3); } +.update-pill.is-checking svg { animation: update-pill-spin 0.9s linear infinite; } + +/* Update available — the one loud state: accent border, tint, pulsing dot. */ +.update-pill.is-update { + color: var(--status-warning, #e0a106); + background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.14); + border-color: var(--status-warning, #e0a106); +} + +.update-pill.is-update:hover { background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.24); } -.update-badge:active { transform: scale(0.97); } - -.update-badge-dot { - width: 8px; - height: 8px; +.update-pill-dot { + width: 7px; + height: 7px; border-radius: 50%; background: var(--status-warning, #e0a106); box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0.6); - animation: update-badge-pulse 2s infinite; + animation: update-pill-pulse 2s infinite; } -@keyframes update-badge-pulse { +@keyframes update-pill-pulse { 0% { box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0.6); } 70% { box-shadow: 0 0 0 7px rgba(var(--status-warning-rgb, 224, 161, 6), 0); } 100% { box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0); } } +@keyframes update-pill-spin { to { transform: rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { - .update-badge-dot { animation: none; } -} - -/* ---- Dashboard banner ---------------------------------------------------- */ - -.update-banner { - display: flex; - align-items: center; - gap: 16px; - margin-bottom: 20px; - padding: 16px 20px; - border: 1px solid var(--status-warning, #e0a106); - border-left-width: 4px; - border-radius: 10px; - background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.1); - color: var(--text-primary, #fff); -} - -/* Subtle "up to date" / local-install variant of the banner. */ -.update-banner.update-banner-ok { - border-color: var(--border-color, rgba(255, 255, 255, 0.12)); - border-left-color: var(--status-success, #2ea043); - background: var(--surface-bg, rgba(255, 255, 255, 0.03)); -} - -.update-banner.update-banner-ok .update-banner-icon { color: var(--status-success, #2ea043); } - -.update-banner.update-banner-ok .update-banner-title { font-weight: 600; } - -.update-banner-icon { - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - color: var(--status-warning, #e0a106); -} - -.update-banner-text { flex: 1 1 auto; min-width: 0; } - -.update-banner-title { font-weight: 700; font-size: 1rem; } - -.update-banner-sub { - margin-top: 2px; - font-size: 0.85rem; - color: var(--text-muted, #9aa); -} - -.update-banner-actions { - display: flex; - gap: 8px; - flex: 0 0 auto; + .update-pill-dot { animation: none; } + .update-pill.is-checking svg { animation: none; } } /* ---- Shared action buttons ----------------------------------------------- */ @@ -229,9 +210,3 @@ gap: 8px; padding: 18px 20px 20px; } - -@media (max-width: 600px) { - .update-banner { flex-wrap: wrap; } - .update-banner-actions { width: 100%; } - .update-banner-actions button { flex: 1 1 auto; } -} diff --git a/containers/libreportal/frontend/core/update-notifier/js/update-notifier.js b/containers/libreportal/frontend/core/update-notifier/js/update-notifier.js index f29ed0c..ad4adda 100644 --- a/containers/libreportal/frontend/core/update-notifier/js/update-notifier.js +++ b/containers/libreportal/frontend/core/update-notifier/js/update-notifier.js @@ -1,13 +1,16 @@ // Update Notifier // ----------------------------------------------------------------------------- -// Surfaces LibrePortal's "out of date" state across the WebUI: -// * a persistent badge in the global topbar (visible on every page), and -// * a banner on the dashboard, -// both driven by /data/system/update_status.json (written host-side by +// Surfaces LibrePortal's version + "out of date" state in one persistent pill +// in the global topbar (visible on every page). The pill stays calm by default — +// the installed version, or "Up to date" — and only escalates to an accented, +// pulsing "Update" when one is actually waiting. Clicking it opens a details +// modal carrying the full version readout and the update / check actions. +// +// Driven by /data/system/update_status.json (written host-side by // webuiSystemUpdateCheck — see scripts/webui/data/generators/system/). // -// The two actions both go through the normal task pipeline so the user can -// watch them stream on the Tasks page, exactly like an app install: +// Both actions go through the normal task pipeline so the user can watch them +// stream on the Tasks page, exactly like an app install: // * "Update now" -> task `libreportal update apply` // * "Check for updates" -> task `libreportal update check` // @@ -21,6 +24,7 @@ class UpdateNotifier { this.pollMs = 5 * 60 * 1000; // re-read the status file every 5 min this.pollTimer = null; this.started = false; + this._checking = false; // a manual "check for updates" is in flight } // ---- data ---------------------------------------------------------------- @@ -47,8 +51,7 @@ class UpdateNotifier { async refresh() { await this.fetchStatus(); - this.renderTopbarBadge(); - this.renderDashboardBanner(); + this.renderTopbarPill(); } // ---- lifecycle ----------------------------------------------------------- @@ -65,7 +68,7 @@ class UpdateNotifier { // first load regardless of which won the race. let tries = 0; const ensure = setInterval(() => { - if (document.querySelector('.topbar-controls')) { this.renderTopbarBadge(); clearInterval(ensure); } + if (document.querySelector('.topbar-controls')) { this.renderTopbarPill(); clearInterval(ensure); } else if (++tries > 30) clearInterval(ensure); // ~15s ceiling }, 500); @@ -77,7 +80,7 @@ class UpdateNotifier { // with the task-refresh coordinator (single source of truth); the debounce // gives the host a beat to finish writing update_status.json. window.taskRefresh?.register({ - id: 'update-badge', + id: 'update-pill', match: (d) => d.action === 'update' || d.action === 'system_update' || /^libreportal update\b/.test((d.task && d.task.command) || d.command || ''), run: () => this.refresh(), @@ -87,121 +90,62 @@ class UpdateNotifier { // Called by TopbarComponent.init() once the topbar DOM exists. onTopbarReady() { - this.renderTopbarBadge(); + this.renderTopbarPill(); this.refresh(); } - // ---- topbar badge -------------------------------------------------------- - - renderTopbarBadge() { + // ---- topbar pill --------------------------------------------------------- + // One persistent status control in the global topbar, on every page. Calm by + // default (the installed version, or "Up to date"); escalates to an accented, + // pulsing "Update" only when one is actually waiting, and shows a spinner + // while a manual check runs. Clicking it opens the details modal. + renderTopbarPill() { const controls = document.querySelector('.topbar-controls'); if (!controls) return; - let badge = document.getElementById('update-badge'); - const show = this.status && this.status.update_available === true; - - if (!show) { - if (badge) badge.remove(); - return; - } - - if (!badge) { - badge = document.createElement('button'); - badge.id = 'update-badge'; - badge.className = 'update-badge'; - badge.type = 'button'; - badge.addEventListener('click', () => this.openPanel()); - controls.insertBefore(badge, controls.firstChild); - } - - const to = this._versionLabel(); - badge.title = `Update available${to ? ' — ' + to : ''}`; - badge.setAttribute('aria-label', badge.title); - badge.innerHTML = ` - - - Update`; - } - - // ---- dashboard banner ---------------------------------------------------- - - renderDashboardBanner() { - const main = document.querySelector('.dashboard-main'); - if (!main) return; // not on the dashboard - - let banner = document.getElementById('update-banner'); const s = this.status; + let pill = document.getElementById('update-pill'); - // No status file yet (fresh install before the first check) — show nothing. - if (!s) { - if (banner) banner.remove(); - return; + // Nothing known yet (fresh install before the first check) — show nothing. + if (!s) { if (pill) pill.remove(); return; } + + if (!pill) { + pill = document.createElement('button'); + pill.id = 'update-pill'; + pill.type = 'button'; + pill.addEventListener('click', () => this.openPanel()); + controls.insertBefore(pill, controls.firstChild); } - if (!banner) { - banner = document.createElement('div'); - banner.id = 'update-banner'; - main.insertBefore(banner, main.firstChild); - } + const refreshIcon = ``; + const checkIcon = ``; + const boxIcon = ``; - const refreshIcon = ` - - - - - - `; - const checkIcon = ` - - - - `; + const ver = (s.current_version && s.current_version !== 'unknown') ? `v${s.current_version}` : ''; + const local = s.source === 'local' || s.install_mode === 'local'; - if (s.update_available) { - // Prominent: an update is waiting. - const versionLine = this._versionLabel() || `${s.behind} update${s.behind === 1 ? '' : 's'} behind`; - const behindLine = s.behind > 0 ? `${s.behind} commit${s.behind === 1 ? '' : 's'} behind` : ''; - banner.className = 'update-banner'; - banner.innerHTML = ` - -
-
A LibrePortal update is available
-
${this._escape(versionLine)}${behindLine ? ` · ${this._escape(behindLine)}` : ''}
-
-
- - ${s.can_update ? '' : ''} -
`; + let state, icon, text, title, dot = ''; + if (this._checking) { + state = 'is-checking'; icon = refreshIcon; text = 'Checking…'; + title = 'Checking for updates…'; + } else if (s.update_available) { + state = 'is-update'; icon = refreshIcon; text = 'Update'; + dot = ''; + const to = this._versionLabel(); + title = `Update available${to ? ' — ' + to : ''}`; + } else if (local) { + // Local/dev install: no remote to check against — show the version plainly. + state = 'is-local'; icon = boxIcon; text = ver || 'Local'; + title = `Local installation — updates are managed manually${ver ? ' · ' + ver : ''}`; } else { - // Subtle: up to date (or a local install). Still gives a version readout - // and a manual "Check for updates" entry point. - const local = s.source === 'local' || s.install_mode === 'local'; - const verText = (s.current_version && s.current_version !== 'unknown') ? `LibrePortal v${s.current_version}` : 'LibrePortal'; - const subText = local ? 'Local installation — updates are managed manually' : 'Up to date'; - banner.className = 'update-banner update-banner-ok'; - banner.innerHTML = ` - -
-
${this._escape(verText)}
-
${this._escape(subText)}
-
-
- - ${local ? '' : ''} -
`; + state = 'is-ok'; icon = checkIcon; text = 'Up to date'; + title = `LibrePortal${ver ? ' ' + ver : ''} · up to date`; } - const details = banner.querySelector('#update-banner-details'); - if (details) details.addEventListener('click', () => this.openPanel()); - const updateBtn = banner.querySelector('#update-banner-update'); - if (updateBtn) updateBtn.addEventListener('click', () => this.runUpdate()); - const checkBtn = banner.querySelector('#update-banner-check'); - if (checkBtn) checkBtn.addEventListener('click', () => this.checkNow()); + pill.className = `update-pill ${state}`; + pill.title = title; + pill.setAttribute('aria-label', title); + pill.innerHTML = `${dot}${icon}${this._escape(text)}`; } // ---- details panel (self-contained modal) -------------------------------- @@ -283,12 +227,17 @@ class UpdateNotifier { } async checkNow() { + this._checking = true; + this.renderTopbarPill(); try { await this._createTask('libreportal update check'); this._toast('Checking for updates…', 'info'); - // The check task rewrites update_status.json; refresh shortly after. - setTimeout(() => this.refresh(), 4000); + // The check task rewrites update_status.json; clear the spinner and + // re-read shortly after so the pill settles on the fresh state. + setTimeout(() => { this._checking = false; this.refresh(); }, 4000); } catch (e) { + this._checking = false; + this.renderTopbarPill(); this._toast('Could not check for updates: ' + e.message, 'error'); } }