Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions ui/js/dashboard-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 42 additions & 8 deletions ui/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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);
Expand Down
67 changes: 66 additions & 1 deletion ui/test/dashboard-logic.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion ui/test/live-region.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading