From 17d5e8bf99098b6071fd0498c31838367d8d5f86 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:57:38 -0400 Subject: [PATCH] Keep the wallpaper and the current page across a reload Every config save bumps _rev, which reloads every open dashboard. Each reload asked Unsplash for a new random photo and reset the page index. Cache the resolved wallpaper URL in localStorage for a day, keyed to the background type and collection, and restore the page index on load. --- CHANGELOG.md | 5 +++ ui/js/dashboard-logic.js | 45 +++++++++++++++++++++ ui/js/dashboard.js | 50 ++++++++++++++++++++---- ui/test/dashboard-logic.test.mjs | 67 +++++++++++++++++++++++++++++++- ui/test/live-region.test.mjs | 9 ++++- 5 files changed, 166 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf17db0f..2ed483de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An Admin save no longer changes the Unsplash wallpaper on every open + dashboard. The photo is kept for a day. + +- The dashboard reopens on the page you were last on instead of the first one. + - The Docker socket proxy address is checked when it is saved, and a wrong one is refused with the reason and what to change about it, instead of being stored. diff --git a/ui/js/dashboard-logic.js b/ui/js/dashboard-logic.js index 3665a3f5..0ed5f079 100644 --- a/ui/js/dashboard-logic.js +++ b/ui/js/dashboard-logic.js @@ -19,6 +19,51 @@ function fingerprint(c) { return JSON.stringify(c?.items?.map(i => `${i?.id}|${i?.label}|${i?.href}`)) + JSON.stringify(c?.settings); } +/** One day. */ +const WALLPAPER_TTL_MS = 24 * 60 * 60 * 1000; + +/** @param {any} bg the background settings @returns {string} */ +function wallpaperKey(bg) { + return `${bg?.type || ''}|${bg?.collection || ''}`; +} + +/** The still-valid wallpaper URL held for these background settings, if any. + + @param {string|null} stored the raw stored entry + @param {any} bg the background settings the page was built from + @param {number} now + @returns {string|null} */ +export function readWallpaperCache(stored, bg, now) { + if (typeof stored !== 'string' || !stored) return null; + let entry; + try { + entry = JSON.parse(stored); + } catch { + return null; + } + if (!entry || typeof entry.url !== 'string' || !entry.url) return null; + if (entry.key !== wallpaperKey(bg)) return null; + if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null; + /* A clock moved backwards would otherwise hold one photo forever. */ + if (now < entry.at || now - entry.at >= WALLPAPER_TTL_MS) return null; + return entry.url; +} + +/** @param {string} url @param {any} bg @param {number} now @returns {string} */ +export function writeWallpaperCache(url, bg, now) { + return JSON.stringify({ url, key: wallpaperKey(bg), at: now }); +} + +/** The page index to open on, clamped to the pages that exist. + + @param {string|null} stored @param {number} totalPages @returns {number} */ +export function restorePage(stored, totalPages) { + const n = Number(stored); + if (stored == null || stored === '' || !Number.isInteger(n) || n < 0) return 0; + if (!Number.isInteger(totalPages) || totalPages < 1) return 0; + return Math.min(n, totalPages - 1); +} + /** Where to send the browser once first-run setup is finished. @param {unknown} items the dashboard items the page was built from diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index a6dd4f0f..37535884 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -27,7 +27,13 @@ import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=dab9978 import { sanitizeItemLinks } from '/js/link-url.js?v=19038560'; import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=1515288b'; import { badgeSignature, computeBadgeVisual } from '/js/badge-logic.js?v=d278c683'; -import { configChanged, landingAfterSetup } from '/js/dashboard-logic.js?v=640430ba'; +import { + configChanged, + landingAfterSetup, + readWallpaperCache, + writeWallpaperCache, + restorePage, +} from '/js/dashboard-logic.js?v=640430ba'; import { trapFocus } from '/js/dialog.js?v=b3841546'; const MOB = innerWidth <= 768 || /iPhone|iPod|Android/i.test(navigator.userAgent); @@ -52,6 +58,25 @@ function desktopSlots() { const CB = { spotOpen: null, spotClose: null, mobPillBump: null }; +const PAGE_STORE = 'dash_page', + WALLPAPER_STORE = 'dash_wallpaper'; + +/** @param {string} key @returns {string|null} */ +function storeGet(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** @param {string} key @param {string} value */ +function storeSet(key, value) { + try { + localStorage.setItem(key, value); + } catch {} +} + let items = [], pg = 0, totalPages = 0, @@ -294,12 +319,13 @@ function announcePage(index, total) { live.textContent = t('home.pageAnnounce', { page: index + 1, total }); } -function goTo(n, dotEls) { +function goTo(n, dotEls, announce = true) { const total = dotEls ? dotEls.length : totalPages; const was = pg; pg = Math.max(0, Math.min(total - 1, n)); - if (pg !== was) announcePage(pg, total); + if (pg !== was && announce) announcePage(pg, total); if (_stateRef) _stateRef.pg = pg; + storeSet(PAGE_STORE, String(pg)); const strip = el('pages'); const t = `translateX(-${pg * 100}vw)`; strip.style.transform = strip.style.webkitTransform = t; @@ -359,16 +385,22 @@ async function applyBg() { root.style.setProperty('--bg-color', '#0d1117'); root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); } else if (bg.type === 'unsplash') { - const r = await fetch('/api/wallpaper', { cache: 'no-store' }); - const d = await r.json(); - if (d.url) { + let url = readWallpaperCache(storeGet(WALLPAPER_STORE), bg, Date.now()); + if (!url) { + const r = await fetch('/api/wallpaper', { cache: 'no-store' }); + const d = await r.json(); + url = d.url || null; + if (url) storeSet(WALLPAPER_STORE, writeWallpaperCache(url, bg, Date.now())); + } + if (url) { + const shown = url; const img = new Image(); img.onload = () => { - root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(d.url)}')`); + root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(shown)}')`); root.style.setProperty('--bg-color', '#0d1117'); root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); }; - img.src = d.url; + img.src = shown; } } } catch {} @@ -603,10 +635,12 @@ async function boot() { requestAnimationFrame(() => { buildMobile(); syncMobPages(); + goTo(restorePage(storeGet(PAGE_STORE), totalPages), null, false); }), ); } else { buildDesktop(); + goTo(restorePage(storeGet(PAGE_STORE), totalPages), null, false); document.addEventListener('keydown', e => { if (el('spot').classList.contains('on')) return; if (e.key === 'ArrowRight') goTo(pg + 1); diff --git a/ui/test/dashboard-logic.test.mjs b/ui/test/dashboard-logic.test.mjs index 08ac8dba..a1245834 100644 --- a/ui/test/dashboard-logic.test.mjs +++ b/ui/test/dashboard-logic.test.mjs @@ -13,7 +13,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { configChanged, landingAfterSetup } from '../js/dashboard-logic.js'; +import { + configChanged, + landingAfterSetup, + readWallpaperCache, + writeWallpaperCache, + restorePage, +} from '../js/dashboard-logic.js'; const loaded = { _rev: 4, @@ -111,6 +117,65 @@ test('a missing loaded config does not throw', () => { assert.doesNotThrow(() => configChanged(undefined, { items: [], settings: {} })); }); +/* ── the wallpaper across a reload ────────────────────────────────────────── */ + +/* Every admin save reloads every open dashboard, and each reload used to ask + Unsplash for another random photo, so an unrelated edit changed the wallpaper + on every screen. */ + +const BG = { type: 'unsplash', collection: '1234' }; +const T0 = 1_760_000_000_000; +const DAY = 24 * 60 * 60 * 1000; + +test('a reload inside the cache window keeps the same photo', () => { + const stored = writeWallpaperCache('https://images/a.jpg', BG, T0); + assert.equal(readWallpaperCache(stored, BG, T0), 'https://images/a.jpg'); + assert.equal(readWallpaperCache(stored, BG, T0 + DAY - 1), 'https://images/a.jpg'); +}); + +test('the photo is replaced once the window has passed', () => { + const stored = writeWallpaperCache('https://images/a.jpg', BG, T0); + assert.equal(readWallpaperCache(stored, BG, T0 + DAY), null); +}); + +test('changing the background settings picks a new photo', () => { + const stored = writeWallpaperCache('https://images/a.jpg', BG, T0); + assert.equal(readWallpaperCache(stored, { ...BG, collection: '9999' }, T0), null); + assert.equal(readWallpaperCache(stored, { type: 'color' }, T0), null); +}); + +/* A clock that moved backwards would otherwise hold one photo indefinitely. */ +test('an entry stamped in the future is discarded', () => { + const stored = writeWallpaperCache('https://images/a.jpg', BG, T0); + assert.equal(readWallpaperCache(stored, BG, T0 - 1000), null); +}); + +test('a missing or unusable entry asks for a new photo', () => { + for (const v of [null, undefined, '', 'not json', '{}', '{"url":"","key":"unsplash|1234","at":0}', '[]']) { + assert.equal(readWallpaperCache(v, BG, T0), null, `for ${JSON.stringify(v)}`); + } + assert.equal(readWallpaperCache(JSON.stringify({ url: 'https://i/a.jpg', key: 'unsplash|1234' }), BG, T0), null); +}); + +/* ── the page index across a reload ───────────────────────────────────────── */ + +test('the page index survives a reload', () => { + const stored = String(2); + assert.equal(restorePage(stored, 4), 2); +}); + +test('a page that no longer exists falls back to the first one', () => { + assert.equal(restorePage('5', 3), 2); + assert.equal(restorePage('1', 1), 0); + assert.equal(restorePage('0', 0), 0); +}); + +test('junk in storage lands on the first page', () => { + for (const v of [null, undefined, '', 'two', '-1', '1.5', 'NaN', '{}']) { + assert.equal(restorePage(v, 4), 0, `for ${JSON.stringify(v)}`); + } +}); + /* Where first-run setup leaves the browser. A fresh install is { items: [], settings: {} } and the setup prompt always diff --git a/ui/test/live-region.test.mjs b/ui/test/live-region.test.mjs index 2ceab41d..5f289419 100644 --- a/ui/test/live-region.test.mjs +++ b/ui/test/live-region.test.mjs @@ -81,7 +81,14 @@ test('nothing is announced when the page did not change', () => { dashboard.indexOf('\n}', dashboard.indexOf('function goTo(')), ); assert.match(goTo, /const was = pg/); - assert.match(goTo, /if \(pg !== was\)/); + assert.match(goTo, /if \(pg !== was( |\))/); +}); + +/* The page restored on load was not chosen by the user, so it is not spoken. */ +test('restoring the page on load announces nothing', () => { + const calls = dashboard.split('\n').filter(l => l.includes('goTo(restorePage(')); + assert.ok(calls.length > 0, 'the page is restored on load'); + for (const line of calls) assert.match(line, /,\s*false\);$/, line.trim()); }); test('the announcement is translated', () => {