diff --git a/public/api/widget-data/w-stats b/public/api/widget-data/w-stats index 1617b97..a986b19 100644 --- a/public/api/widget-data/w-stats +++ b/public/api/widget-data/w-stats @@ -1 +1 @@ -{"cpu":12,"ram":38,"temp":39,"temps":{"0":39},"disks":[{"mount":"/","usedPct":42.8,"totalGb":233},{"mount":"/srv/pool","usedPct":67.5,"totalGb":7452}],"iowait":0.4,"procs":186,"uptime":4823910,"download":412.6,"upload":38.4} +{"cpu":12,"ram":38,"temp":39,"temps":{"0":39},"disks":[{"mount":"/","usedPct":42.8,"totalGb":233},{"mount":"/srv/pool","usedPct":67.5,"totalGb":7452}],"iowait":0.4,"procs":186,"uptime":4823910,"download":412.6,"upload":38.4,"history":{"cpu":[12.0,17.0,20.2,20.7,19.0,16.4,14.6,14.3,15.2,16.0,15.4,12.5,8.1,3.7,1.2,1.6,4.7,8.9,12.6,14.5,14.4,13.6,13.3,14.7,17.4,20.3,21.7,20.3,16.4,11.2,6.7,4.4,4.5,6.4,8.4,9.2,8.5,7.1,6.6,12],"ram":[39.5,38.3,38.7,39.0,39.2,39.5,39.7,41.3,39.9,40.0,40.0,39.9,39.8,39.7,40.9,39.2,38.9,38.6,38.3,37.9,37.6,38.8,37.0,36.7,36.5,36.3,36.1,36.0,37.5,36.0,36.1,36.2,36.4,36.6,36.8,38.6,37.4,37.8,38.1,38]}} \ No newline at end of file diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index b4ba93a..542d0a7 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,4 +1,4 @@ -import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=fdc0243f'; +import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=0d64dfd3'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c7b5111'; import { state } from '/js/admin-state.js?v=af772a1b'; @@ -24,13 +24,13 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=d0ef41ba'; -import { createListbox } from '/js/listbox.js?v=11c869c4'; +} from '/js/admin-shared.js?v=2b48ff3f'; +import { createListbox } from '/js/listbox.js?v=35d8318a'; import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=cee08df9'; -import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=a9a7e60c'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=3b14e88c'; +import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { iconSvg } from '/js/icon-set.js?v=34af798f'; +import { iconSvg } from '/js/icon-set.js?v=628341d4'; export function buildFolderForm(body, item) { const children = item?.children || []; diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index 7450111..2833df8 100644 --- a/public/js/admin-auth.js +++ b/public/js/admin-auth.js @@ -1,8 +1,8 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=d0ef41ba'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=2b48ff3f'; import { initI18n, t } from '/js/i18n.js?v=5579776a'; -import { loginErrorKey } from '/js/admin-error.js?v=a9a7e60c'; +import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { el, inp as inpById, qa } from '/js/utils.js?v=fdc0243f'; +import { el, inp as inpById, qa } from '/js/utils.js?v=0d64dfd3'; import { blockingScreenFor } from '/js/config-recovery.js?v=dbe542e1'; export async function checkAuth(onLogin) { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index 57c0e2d..452a45e 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,8 +1,8 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=d0ef41ba'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=2b48ff3f'; import { t } from '/js/i18n.js?v=5579776a'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=fdc0243f'; -import { iconSvg } from '/js/icon-set.js?v=34af798f'; +import { qa, q } from '/js/utils.js?v=0d64dfd3'; +import { iconSvg } from '/js/icon-set.js?v=628341d4'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js index d854315..5f29ba1 100644 --- a/public/js/admin-drag.js +++ b/public/js/admin-drag.js @@ -11,8 +11,8 @@ import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; import { state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=ae65f9c8'; -import { qa } from '/js/utils.js?v=fdc0243f'; +import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; +import { qa } from '/js/utils.js?v=0d64dfd3'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/public/js/admin-error.js b/public/js/admin-error.js index 98cd58c..72c8c90 100644 --- a/public/js/admin-error.js +++ b/public/js/admin-error.js @@ -34,6 +34,7 @@ const BY_CODE = Object.freeze({ 'blocked.unresolved': 'adminError.unreachable', 'invalid.duplicate-id': 'adminError.duplicateId', 'invalid.missing-children': 'adminError.missingChildren', + 'invalid.bad-children': 'adminError.badChildren', 'invalid.unsafe-link': 'adminError.unsafeLink', 'invalid.dock-full': 'app.dockFull', 'network.refused': 'adminError.refused', diff --git a/public/js/admin-list.js b/public/js/admin-list.js index ef55965..79fedc2 100644 --- a/public/js/admin-list.js +++ b/public/js/admin-list.js @@ -12,16 +12,16 @@ reads and writes them too. */ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=ae65f9c8'; +import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=5e9c57c7'; -import { paintIcon } from '/js/admin-shared.js?v=d0ef41ba'; -import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=fdc0243f'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=14a608cd'; +import { paintIcon } from '/js/admin-shared.js?v=2b48ff3f'; +import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=0d64dfd3'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=5579776a'; -import { sizeLabel } from '/js/admin-widget-form.js?v=ea4e9101'; -import { widgetGlyph } from '/js/widget-glyphs.js?v=648cc374'; -import { iconSvg } from '/js/icon-set.js?v=34af798f'; +import { sizeLabel } from '/js/admin-widget-form.js?v=8bb8feb4'; +import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; +import { iconSvg } from '/js/icon-set.js?v=628341d4'; /** @type {{ openModal: (idx: number|null) => void, openFolderPicker: (appId: string|null, targetFolderId?: string|null) => void, diff --git a/public/js/admin-save-logic.js b/public/js/admin-save-logic.js index 8b2de7e..f6e4466 100644 --- a/public/js/admin-save-logic.js +++ b/public/js/admin-save-logic.js @@ -110,6 +110,59 @@ export function afterImport({ local, saved, current, serverItems, newItems }) { return { items: [...local, ...newItems], saved: [...saved, ...newItems], stale: false }; } +/** @param {unknown} a @param {unknown} b @returns {boolean} equal data, whatever the key order */ +function sameData(a, b) { + if (a === b) return true; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const ka = Object.keys(a); + return ( + ka.length === Object.keys(b).length && + ka.every(k => Object.hasOwn(b, k) && sameData(/** @type {any} */ (a)[k], /** @type {any} */ (b)[k])) + ); +} + +/** The settings a backup restores. The server owns `auth` and drops it from + every write. + @param {unknown} settings @returns {Record | null} */ +function restorableSettings(settings) { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return null; + const { auth: _auth, ...rest } = /** @type {Record} */ (settings); + return rest; +} + +/** @param {any} file a backup @param {any} live the config as the server holds it + @returns {boolean} whether restoring the file changes the settings */ +export function restoresSettings(file, live) { + const next = restorableSettings(file?.settings); + if (!next) return false; + const now = restorableSettings(live?.settings) || {}; + /* The server keeps the stored host list when a write leaves it out. */ + const server = /** @type {any} */ (next.server); + const kept = /** @type {any} */ (now.server)?.allowedHosts; + if (server?.allowedHosts === undefined && kept !== undefined) next.server = { ...server, allowedHosts: kept }; + return !sameData(next, now); +} + +/** The config write that restores a backup over `live`. It carries the file's + schema version so the server migrates what an older release wrote. + @param {any} file @param {any} live + @param {boolean} [keepBackground] the file names an uploaded wallpaper this install does not hold + @returns {any} */ +export function restoreBody(file, live, keepBackground = false) { + const settings = restorableSettings(file?.settings); + if (settings && keepBackground) { + if (live?.settings?.background) settings.background = live.settings.background; + else delete settings.background; + } + return { + _rev: live?._rev, + _schemaVersion: file?._schemaVersion, + items: file?.items, + settings: settings || live?.settings, + }; +} + /** Run writes one at a time, in the order asked. A write asked for while another runs waits for it instead of being dropped. diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index 695d0c7..2e93c5f 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,5 +1,5 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=d0ef41ba'; -import { socketProbeAdvice } from '/js/admin-error.js?v=a9a7e60c'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=2b48ff3f'; +import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=5579776a'; import { @@ -11,11 +11,11 @@ import { BLOCK, } from '/js/admin-logic.js?v=fc7f0836'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; -import { el, inp, setUserText } from '/js/utils.js?v=fdc0243f'; +import { el, inp, setUserText } from '/js/utils.js?v=0d64dfd3'; import { formatNumber } from '/js/format-number.js?v=349a741d'; -import { serialWrites } from '/js/admin-save-logic.js?v=ae65f9c8'; -import { renderColorControl } from '/js/admin-color-control.js?v=cee08df9'; -import { BACKDROP } from '/js/background.js?v=abd33088'; +import { serialWrites } from '/js/admin-save-logic.js?v=5811620e'; +import { renderColorControl } from '/js/admin-color-control.js?v=3b14e88c'; +import { BACKDROP } from '/js/background.js?v=fe705356'; import { ALLOWED_HOSTS_MAX, firstBadHost, diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index 84eddb8..3d39d32 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,22 +1,27 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=fc7f0836'; -import { el, isolate, q } from '/js/utils.js?v=fdc0243f'; +import { el, isolate, q } from '/js/utils.js?v=0d64dfd3'; import { t } from '/js/i18n.js?v=5579776a'; -import { errorAdvice } from '/js/admin-error.js?v=a9a7e60c'; +import { errorAdvice } from '/js/admin-error.js?v=b0881957'; import { formatNumber } from '/js/format-number.js?v=349a741d'; import { iconChain } from '/js/icons.js?v=9c7b5111'; -import { iconSvg } from '/js/icon-set.js?v=34af798f'; +import { iconSvg } from '/js/icon-set.js?v=628341d4'; import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=dbe542e1'; export const API = ''; let tt; let _toastWired = false; +let _pinned = false; -/** @param {string} m @param {'ok'|'err'} [tone] @returns {void} */ -export const toast = (m, tone = 'ok') => { +/** @param {string} m @param {'ok'|'err'} [tone] + @param {{ pin?: boolean }} [opts] pin: no 'ok' notice replaces it until it is dismissed + @returns {void} */ +export const toast = (m, tone = 'ok', { pin = false } = {}) => { const e = el('toast'); if (!e) return; + if (tone === 'ok' && _pinned && e.classList.contains('show')) return; + _pinned = pin; e.textContent = m; e.className = `show ${tone}`; clearTimeout(tt); @@ -35,6 +40,7 @@ export const toast = (m, tone = 'ok') => { e.addEventListener('click', () => { clearTimeout(tt); e.className = ''; + _pinned = false; }); } const ms = toastHoldMs(tone, m, 'show'); @@ -135,13 +141,20 @@ export const apiPost = async (p, b, recover = true) => { const body = await r.json(); /* Every config write goes through here, and a withheld credential has to be said out loud. */ - const withheld = (body?.withheld || []) + const withheld = withheldText(body); + if (withheld) toast(withheld, 'err', { pin: true }); + return body; +}; + +/** @param {any} body a config write's reply + @returns {string} the notice naming the credentials it withheld, or '' */ +export function withheldText(body) { + const names = (body?.withheld || []) .map(w => w.label) .filter(Boolean) .map(isolate); - if (withheld.length) toast(t('toast.secretsWithheld', { items: withheld.join(', ') }), 'err'); - return body; -}; + return names.length ? t('toast.secretsWithheld', { items: names.join(', ') }) : ''; +} /* A native `disabled` control is skipped by screen readers. aria-disabled keeps it announced but carries no behaviour, so activation is blocked here. */ diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 9009848..13cff43 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,12 +1,12 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=d0ef41ba'; -import { createListbox } from '/js/listbox.js?v=11c869c4'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=effb84f9'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=2b48ff3f'; +import { createListbox } from '/js/listbox.js?v=35d8318a'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=02ffdaba'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=fc7f0836'; import { t } from '/js/i18n.js?v=5579776a'; -import { focusFirst, q, qi, qa } from '/js/utils.js?v=fdc0243f'; -import { iconSvg } from '/js/icon-set.js?v=34af798f'; +import { focusFirst, q, qi, qa } from '/js/utils.js?v=0d64dfd3'; +import { iconSvg } from '/js/icon-set.js?v=628341d4'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/public/js/admin.js b/public/js/admin.js index e3d9938..c976ed1 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,25 +1,27 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=c5eecf98'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=c54047e3'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=7e288a05'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=db741421'; import { recoveryShown } from '/js/config-recovery.js?v=dbe542e1'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=348d1d69'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=e7e8ec6e'; import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; import { afterImport, buildAppItem, claimFolderChildren, newItemId, + restoreBody, + restoresSettings, revertingSaves, serialWrites, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=ae65f9c8'; +} from '/js/admin-save-logic.js?v=5811620e'; import { loadSettings, savedWallpaperUrl, settingsDirty, showBgFields, showWallpaperFile, -} from '/js/admin-settings.js?v=08a3c4c9'; +} from '/js/admin-settings.js?v=a5dc252e'; import { apiGet, apiPost, @@ -32,17 +34,18 @@ import { ShownError, setReauthHandler, toast, -} from '/js/admin-shared.js?v=d0ef41ba'; + withheldText, +} from '/js/admin-shared.js?v=2b48ff3f'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=ea4e9101'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=8bb8feb4'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=349a741d'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=11c869c4'; +import { createListbox } from '/js/listbox.js?v=35d8318a'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=5579776a'; import { loadLocalIcons } from '/js/icons.js?v=9c7b5111'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=34af798f'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=628341d4'; import { clearSkipTls, convert, @@ -52,7 +55,7 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=9efb127f'; +} from '/js/import-foreign.js?v=6ecd782f'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { @@ -77,9 +80,9 @@ import { storeGet, storeSet, tgt, -} from '/js/utils.js?v=fdc0243f'; -import { applyBackground, resolveBackground } from '/js/background.js?v=abd33088'; -import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c'; +} from '/js/utils.js?v=0d64dfd3'; +import { applyBackground, resolveBackground } from '/js/background.js?v=fe705356'; +import { parseYamlTolerant, YamlLiteError, YamlTooLargeError } from '/js/yaml-lite.js?v=a42ef914'; ensureSprite(); @@ -106,6 +109,7 @@ async function load() { qa('.ie-row').forEach(nameEditPen); document.title = t('nav.pageTitle'); initVersion(); + initAboutLogo(); syncPickerLabels(); try { const wr = await apiGet('/api/widgets'); @@ -217,6 +221,55 @@ async function appendItems(newItems) { } } +/** @param {unknown} url @returns {Promise} whether `url` names an uploaded wallpaper the server answers 404 for */ +async function wallpaperMissing(url) { + if (typeof url !== 'string' || !url.startsWith('/icons/wallpaper/')) return false; + try { + return (await fetch(url, { method: 'HEAD', cache: 'no-store' })).status === 404; + } catch { + return false; + } +} + +/** Replace the list and the settings with a backup's. Returns whether the write + reached the server. + @param {any} file */ +function restoreBackup(file) { + return saves.run(() => writeBackup(file)); +} + +/** @param {any} file */ +async function writeBackup(file) { + try { + const live = await apiGet('/api/config'); + if (JSON.stringify(live.items || []) !== _serverItems) { + throw Object.assign(new Error('stale'), { status: 409 }); + } + const keepBackground = await wallpaperMissing(file?.settings?.background?.url); + const r = await apiPost('/api/config', restoreBody(file, live, keepBackground)); + _replaced++; + state.items = r.items; + _serverItems = JSON.stringify(r.items); + _savedItems = _serverItems; + saveOrRestore.landed(JSON.parse(_savedItems)); + const notice = [withheldText(r), restoresSettings(file, live) ? t('toast.reloadToFinishRestore') : ''] + .filter(Boolean) + .join(' '); + if (notice) toast(notice, 'err', { pin: true }); + else toast(t('toast.imported')); + return true; + } catch (e) { + toast( + e.status === 409 ? t('toast.dashboardChangedElsewhere') : t('toast.importFailed', { err: errorText(e) }), + 'err', + ); + return false; + } finally { + render(); + syncDashSave(); + } +} + const saveOrRestore = revertingSaves({ write: save, restore: items => { @@ -757,6 +810,30 @@ function initAllInlineEdits() { }); } +/* The arc turns once, as on the docs site, when the logo is pointed at or + tapped. Never on its own. */ +function initAboutLogo() { + const wrap = document.querySelector('.about-ic-wrap'); + const orbit = document.querySelector('.about-orbit'); + if (!wrap || !orbit || typeof orbit.animate !== 'function') return; + const reduce = matchMedia('(prefers-reduced-motion: reduce)'); + let run = null; + const spin = () => { + if (run || reduce.matches) return; + run = orbit.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { + duration: 1000, + easing: 'cubic-bezier(.65,0,.35,1)', + }); + run.onfinish = run.oncancel = () => { + run = null; + }; + }; + wrap.addEventListener('pointerenter', e => { + if (/** @type {PointerEvent} */ (e).pointerType !== 'touch') spin(); + }); + wrap.addEventListener('click', spin); +} + async function initVersion() { try { const d = await apiGet('/api/version'); @@ -1097,7 +1174,8 @@ el('imp').onchange = async e => { for (const id of cur.keys()) { if (!inc.has(id)) deleted++; } - if (added + updated + deleted === 0) { + const settingsToo = restoresSettings(d, await apiGet('/api/config')); + if (added + updated + deleted === 0 && !settingsToo) { toast(t('toast.importNoChange')); tgt(e).value = ''; return; @@ -1110,6 +1188,7 @@ el('imp').onchange = async e => { updated: formatNumber(updated), deleted: formatNumber(deleted), }); + if (settingsToo) lead.textContent += ` ${t('import.settingsToo')}`; const ok = await confirmModal({ title: t('import.confirmTitle'), body: lead, @@ -1121,9 +1200,7 @@ el('imp').onchange = async e => { tgt(e).value = ''; return; } - const before = snapshotItems(state.items); - state.items = d.items; - if (await saveOrRevert(before)) toast(t('toast.imported')); + await restoreBackup(d); } catch (err) { const why = err instanceof SyntaxError ? t('toast.importNotStackyard') : errorText(err); toast(t('toast.importFailed', { err: why }), 'err'); @@ -1153,6 +1230,8 @@ const NOTE_TEXT = { [NOTE.LOCAL_URL_DROPPED]: 'importForeign.noteLocalUrl', [NOTE.FIELDS_DROPPED]: 'importForeign.noteFields', [NOTE.PAGES_NOT_FOLLOWED]: 'importForeign.notePages', + [NOTE.SCHEME_ADDED]: 'importForeign.noteSchemeAdded', + [NOTE.HEALTH_URL_DROPPED]: 'importForeign.noteHealthUrl', }; /** One "Heading (n)" block followed by a line per entry. @@ -1199,6 +1278,8 @@ el('imp-foreign').onchange = async e => { } catch (err) { if (err instanceof YamlLiteError) throw new ShownError(t('toast.importYamlUnsupported', { file: isolate(file.name), line: err.line })); + if (err instanceof YamlTooLargeError) + throw new ShownError(t('toast.importTooLarge', { file: isolate(file.name) })); throw err; } let out; diff --git a/public/js/background.js b/public/js/background.js index 2ca5be2..9214749 100644 --- a/public/js/background.js +++ b/public/js/background.js @@ -1,5 +1,5 @@ // @ts-check -import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=fdc0243f'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=0d64dfd3'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/public/js/dashboard-logic.js b/public/js/dashboard-logic.js index f6b7474..25708de 100644 --- a/public/js/dashboard-logic.js +++ b/public/js/dashboard-logic.js @@ -102,3 +102,27 @@ export function restorePage(stored, totalPages) { export function landingAfterSetup(items) { return Array.isArray(items) && items.length === 0 ? '/admin' : null; } + +/** One page per sideways wheel gesture. A trackpad swipe or a thumb wheel sends + a stream of events, momentum included. The page turns once the stream has + travelled far enough sideways, and not again until the stream pauses. + + @param {{ threshold?: number, quietMs?: number }} [opts] + @returns {(dx: number, dy: number, t: number) => -1 | 0 | 1} */ +export function wheelPager({ threshold = 40, quietMs = 220 } = {}) { + let sum = 0; + let last = Number.NEGATIVE_INFINITY; + let spent = false; + return (dx, dy, t) => { + if (t - last > quietMs) { + sum = 0; + spent = false; + } + last = t; + if (spent || Math.abs(dx) <= Math.abs(dy)) return 0; + sum += dx; + if (Math.abs(sum) < threshold) return 0; + spent = true; + return sum > 0 ? 1 : -1; + }; +} diff --git a/public/js/dashboard.js b/public/js/dashboard.js index a53a29a..b15a94c 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -22,19 +22,20 @@ import { q, qa, qi, + scrollsSideways, setUserText, storeGet, storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=fdc0243f'; +} from '/js/utils.js?v=0d64dfd3'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=6b79fa8a'; +import { initSpotlight } from '/js/spotlight.js?v=e89d7f89'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=5579776a'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=dbe542e1'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=389e0ed0'; -import { setupErrorKey } from '/js/admin-error.js?v=a9a7e60c'; +import { setupErrorKey } from '/js/admin-error.js?v=b0881957'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { @@ -45,7 +46,7 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=1d2ce8a6'; +} from '/js/ui.js?v=3c85d5d7'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=9e6d9d4b'; import { formatNumber } from '/js/format-number.js?v=349a741d'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=1e0ab5d1'; @@ -56,13 +57,14 @@ import { desktopPages, landingAfterSetup, restorePage, -} from '/js/dashboard-logic.js?v=0d519f8b'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=abd33088'; + wheelPager, +} from '/js/dashboard-logic.js?v=6a29cedc'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=fe705356'; import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=34af798f'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=628341d4'; import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43'; import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5'; @@ -783,7 +785,7 @@ async function boot() { /* API down, handled below */ } - let configFailed = false; + let apiFailed = false; try { const got = await configReq; if (got.e) throw got.e; @@ -798,12 +800,19 @@ async function boot() { await initI18n(S.language || 'en'); } catch (e) { console.error('[boot]', e); - configFailed = true; + apiFailed = true; } await iconsReq; - if (configFailed) { + /* Without the list every widget renders as a blank frame. */ + if (!apiFailed && (await widgetsReq).e) widgetsReq = loadWidgets(); + if (!apiFailed && (await widgetsReq).e) { + console.error('[boot]', (await widgetsReq).e); + apiFailed = true; + } + + if (apiFailed) { /* The catalog is loaded as the last step of the fetch that just failed, so without this the only screen left renders its own keys. nginx serves this file, the API served the config, so it is still reachable. */ @@ -909,6 +918,26 @@ async function boot() { if (e.key === 'ArrowRight') goTo(pg + pageDir()); if (e.key === 'ArrowLeft') goTo(pg - pageDir()); }); + /* A trackpad swipe or a thumb wheel turns one page per gesture. Widget frames + hand their wheel to the card, so this also runs over widgets. */ + const wheelStep = wheelPager(); + document.addEventListener( + 'wheel', + e => { + if (e.ctrlKey) return; + if (el('spot').classList.contains('on')) return; + if (document.querySelector('.folder-overlay, .folder-overlay-mobile')) return; + const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerWidth : 1; + const dx = e.deltaX * unit, + dy = e.deltaY * unit; + if (Math.abs(dx) <= Math.abs(dy) || scrollsSideways(e.target, dx)) return; + /* Unhandled, a sideways swipe is the browser's back and forward gesture. */ + e.preventDefault(); + const step = wheelStep(dx, dy, e.timeStamp); + if (step) goTo(pg + step * pageDir()); + }, + { passive: false }, + ); let _dMx = 0, _dDragging = false; document.addEventListener('mousedown', e => { diff --git a/public/js/icon-set.js b/public/js/icon-set.js index c1c5676..12c9d1a 100644 --- a/public/js/icon-set.js +++ b/public/js/icon-set.js @@ -139,6 +139,8 @@ function logoIcon() { }; } +const LOGO = logoIcon(); + function sizeIcon(k) { const [x, y, w, h] = { small: [5, 5, 14, 14], medium: [2, 7, 20, 10], large: [3, 3, 18, 18], xlarge: [5, 2, 14, 20] }[ k @@ -272,7 +274,10 @@ export const ICONS = { 'arrow-down': { shapes: [line('M12 4v15M6 13l6 6 6-6')] }, 'arrow-up': { shapes: [line('M12 20V5M6 11l6-6 6 6')] }, - logo: logoIcon(), + logo: LOGO, + /* The About logo turns its arc alone, so it needs the two halves apart. */ + 'logo-ink': { inkMask: false, shapes: LOGO.shapes.slice(0, 3) }, + 'logo-arc': { inkMask: false, shapes: LOGO.shapes.slice(3) }, start: { inkMask: false, shapes: [line('M12 2v9M8 7l4 4 4-4', 'accent'), rect(2, 14, 20, 8, 4), bar(5, 17, 6)] }, run: { gap: 0, shapes: [rect(2, 4, 20, 16, 4), line('M6 9l3 3-3 3', 'accent'), bar(12, 14, 6)] }, create: { shapes: [rect(3, 5, 16, 16, 4), line('M19 3v4M17 5h4', 'accent'), bar(6, 10, 6), bar(6, 14, 10)] }, diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js index 1f5fabf..423cba8 100644 --- a/public/js/import-foreign.js +++ b/public/js/import-foreign.js @@ -3,7 +3,7 @@ only: a widget in the source becomes a plain app tile, never a Stackyard widget. Keep this module free of the DOM and of the network. */ -import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=ae65f9c8'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=5811620e'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ @@ -33,6 +33,8 @@ export const NOTE = Object.freeze({ LOCAL_URL_DROPPED: 'local-url-dropped', FIELDS_DROPPED: 'fields-dropped', PAGES_NOT_FOLLOWED: 'pages-not-followed', + SCHEME_ADDED: 'scheme-added', + HEALTH_URL_DROPPED: 'health-url-dropped', }); /* Fields with no equivalent on a Stackyard item. */ @@ -60,6 +62,9 @@ function isAbsoluteLink(href) { return href.startsWith('//'); } +/** @param {string} href @returns {string} `href` with http:// added when it is a bare host:port */ +const withScheme = href => (/^[a-z0-9.-]+:\d{1,5}(?:[/?#]|$)/i.test(href) ? `http://${href}` : href); + /* A value Homepage or Dashy resolves from its own environment. */ const hasPlaceholder = s => /\{\{[^}]*\}\}|\$\{[^}]*\}/.test(s); @@ -175,7 +180,8 @@ function collector(takenIds) { @param {{ label: string, href: string, iconUrl: string, container: string, pingUrl: string, skipTlsVerify?: boolean }} parts @returns {any|null} */ -function addApp(col, group, { label, href, iconUrl, container, pingUrl, skipTlsVerify }) { +function addApp(col, group, { label, href: raw, iconUrl, container, pingUrl: rawPing, skipTlsVerify }) { + const href = withScheme(raw); if (!label) { col.skip(SKIP.NO_LABEL, href || '', group); return null; @@ -197,6 +203,11 @@ function addApp(col, group, { label, href, iconUrl, container, pingUrl, skipTlsV col.skip(SKIP.UNSAFE_HREF, label, group, href); return null; } + let pingUrl = withScheme(rawPing); + if (pingUrl && (hasPlaceholder(pingUrl) || !/^https?:\/\//i.test(pingUrl))) { + col.note(NOTE.HEALTH_URL_DROPPED, label, group, rawPing); + pingUrl = ''; + } const built = buildAppItem( { label, @@ -220,6 +231,7 @@ function addApp(col, group, { label, href, iconUrl, container, pingUrl, skipTlsV } col.taken.add(built.item.id); col.items.push(built.item); + if (href !== raw) col.note(NOTE.SCHEME_ADDED, label, group, raw); return built.item; } diff --git a/public/js/listbox.js b/public/js/listbox.js index 66f9e8b..63f1d8d 100644 --- a/public/js/listbox.js +++ b/public/js/listbox.js @@ -9,8 +9,8 @@ import { nextActiveIndex } from '/js/admin-logic.js?v=fc7f0836'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=fdc0243f'; -import { iconSvg } from '/js/icon-set.js?v=34af798f'; +import { qa } from '/js/utils.js?v=0d64dfd3'; +import { iconSvg } from '/js/icon-set.js?v=628341d4'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/public/js/spotlight.js b/public/js/spotlight.js index dc6a419..9e43568 100644 --- a/public/js/spotlight.js +++ b/public/js/spotlight.js @@ -1,5 +1,5 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=fdc0243f'; +import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=0d64dfd3'; import { t } from '/js/i18n.js?v=5579776a'; import { formatNumber } from '/js/format-number.js?v=349a741d'; diff --git a/public/js/ui.js b/public/js/ui.js index 2884c7d..f698341 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -16,7 +16,7 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=fdc0243f'; +} from '/js/utils.js?v=0d64dfd3'; import { t, currentLang } from '/js/i18n.js?v=5579776a'; import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300'; diff --git a/public/js/utils.js b/public/js/utils.js index 589549d..d81e2bd 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -291,6 +291,21 @@ const isCrossOrigin = src => { } }; +/** Whether a wheel moving dx sideways belongs to something under the pointer + that can still scroll that way. + + @param {EventTarget | null} target @param {number} dx */ +export function scrollsSideways(target, dx) { + for (let n = /** @type {any} */ (target); n && n.nodeType === 1; n = n.parentElement) { + const view = n.ownerDocument && n.ownerDocument.defaultView; + if (!view || n.scrollWidth <= n.clientWidth) continue; + if (!/(auto|scroll)/.test(view.getComputedStyle(n).overflowX)) continue; + const left = Math.abs(n.scrollLeft); + if (dx > 0 ? left + n.clientWidth < n.scrollWidth - 1 : left > 0) return true; + } + return false; +} + /** @param {HTMLElement} card @param {{ src?: string, title?: string, design?: [number, number], @@ -338,8 +353,54 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla doc.documentElement.setAttribute('dir', root.getAttribute('dir') || 'ltr'); doc.documentElement.setAttribute('lang', root.getAttribute('lang') || 'en'); }; + /* A bundled widget page is zoomed to the card rather than scaled as a + picture. A scaled frame is resampled, and blurs on a 1x screen. Any other + page lays out at the design size it was written for. */ + const zoomable = /^\/widgets\//.test(src); + const applyZoom = () => { + try { + const doc = ifr.contentDocument; + if (doc && doc.documentElement) doc.documentElement.style.zoom = ifr.getAttribute('data-zoom') || ''; + } catch {} + }; + /* A wheel over a frame never reaches the dashboard. Hand a bundled page's + wheel to the card, so sideways scrolling turns pages over widgets too. */ + let wheelWin = null; + const forwardWheel = e => { + if (e.ctrlKey || scrollsSideways(e.target, e.deltaX)) return; + const copy = new WheelEvent('wheel', { + deltaX: e.deltaX, + deltaY: e.deltaY, + deltaMode: e.deltaMode, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(copy, 'timeStamp', { + value: e.timeStamp + (wheelWin?.performance.timeOrigin ?? performance.timeOrigin) - performance.timeOrigin, + }); + card.dispatchEvent(copy); + if (copy.defaultPrevented) e.preventDefault(); + }; + const attachWheel = () => { + try { + if (wheelWin) wheelWin.removeEventListener('wheel', forwardWheel); + wheelWin = ifr.contentWindow; + if (wheelWin) wheelWin.addEventListener('wheel', forwardWheel, { passive: false }); + } catch { + wheelWin = null; + } + }; ifr.addEventListener('load', applyDir); - cleanups.push(() => ifr.removeEventListener('load', applyDir)); + ifr.addEventListener('load', applyZoom); + if (zoomable) ifr.addEventListener('load', attachWheel); + cleanups.push(() => { + ifr.removeEventListener('load', applyDir); + ifr.removeEventListener('load', applyZoom); + ifr.removeEventListener('load', attachWheel); + try { + if (wheelWin) wheelWin.removeEventListener('wheel', forwardWheel); + } catch {} + }); try { if (ifr.contentDocument && ifr.contentDocument.readyState === 'complete') applyDir(); } catch {} @@ -453,9 +514,18 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla fills the card exactly. Where the card's aspect drifts from the design's, cover crops the widget instead, and the card clips what it cropped. */ const s = Math.min(w / dw, h / dh); - const tx = (w - dw * s) / 2, - ty = (h - dh * s) / 2; - ifr.style.transform = `translate(${tx}px, ${ty}px) scale(${s})`; + /* Whole pixels. A fractional offset or size blurs the page on a 1x screen. */ + if (zoomable) { + const fw = Math.round(dw * s), + fh = Math.round(dh * s); + ifr.style.width = fw + 'px'; + ifr.style.height = fh + 'px'; + ifr.style.transform = `translate(${Math.round((w - fw) / 2)}px, ${Math.round((h - fh) / 2)}px)`; + ifr.setAttribute('data-zoom', String(fw / dw)); + applyZoom(); + } else { + ifr.style.transform = `translate(${Math.round((w - dw * s) / 2)}px, ${Math.round((h - dh * s) / 2)}px) scale(${s})`; + } ifr.style.opacity = '1'; /* reveal only once scaled, avoids the flash of unscaled content on load */ }; if (typeof ResizeObserver !== 'undefined') { diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index 7ce9b73..d85d2ef 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=5579776a'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=d0ef41ba'; -import { createListbox } from '/js/listbox.js?v=11c869c4'; -import { renderColorControl } from '/js/admin-color-control.js?v=cee08df9'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=2b48ff3f'; +import { createListbox } from '/js/listbox.js?v=35d8318a'; +import { renderColorControl } from '/js/admin-color-control.js?v=3b14e88c'; import { seedCarried, applyOptionSet, @@ -14,8 +14,8 @@ import { groupBounds, visibleFieldFlags, } from '/js/admin-logic.js?v=fc7f0836'; -import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=a9a7e60c'; -import { qi } from '/js/utils.js?v=fdc0243f'; +import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; +import { qi } from '/js/utils.js?v=0d64dfd3'; import { formatNumber } from '/js/format-number.js?v=349a741d'; const PE = diff --git a/public/js/widget-error.js b/public/js/widget-error.js index cbe7a68..96bf6a0 100644 --- a/public/js/widget-error.js +++ b/public/js/widget-error.js @@ -99,6 +99,7 @@ export const ERROR_KINDS = Object.freeze(Object.keys(COPY)); const STYLE_ID = 'wt-error-css'; const CSS = ` .wt-inert { filter: grayscale(0.9) opacity(0.5); transition: filter 0.4s ease; pointer-events: none; } +.wt-gone { visibility: hidden; } .wt-cap { display: flex; align-items: center; gap: 5px; min-width: 0; font-size: 11px; font-weight: 500; line-height: 1.3; color: var(--wt-cap-color, rgba(255,255,255,0.62)); /* The caption is never a target. A centred one covers its whole widget, and @@ -107,13 +108,13 @@ const CSS = ` /* This rule sets display, which outranks the user agent's [hidden] rule. Without its own hidden rule the caption never goes away. */ .wt-cap[hidden] { display: none; } -html[data-theme="light"] .wt-cap { color: var(--wt-cap-color, rgba(60,60,67,0.7)); } +html[data-theme="light"] .wt-cap { color: var(--wt-cap-color, #636366); } .wt-cap svg { width: 12px; height: 12px; flex: 0 0 auto; opacity: 0.85; } .wt-cap b { font-weight: 500; overflow: hidden; min-width: 0; /* Two lines, then clip. The narrowest card cannot hold the longest line on one, and an ellipsised failure does not say what failed. */ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } -.wt-cap i { font-style: normal; opacity: 0.7; flex: 0 0 auto; } +.wt-cap i { font-style: normal; flex: 0 0 auto; } .wt-cap-auto { position: absolute; inset-inline: 16px; bottom: 12px; } .wt-cap-center { position: absolute; inset: 0; justify-content: center; text-align: center; padding: 0 14px; } @media (prefers-reduced-motion: reduce) { .wt-inert { transition: none; } } @@ -191,11 +192,14 @@ export function errorState(opts = {}) { }, /** @param {string} text */ empty(text) { - for (const el of contentOf()) el.classList.remove('wt-inert'); + for (const el of contentOf()) { + el.classList.remove('wt-inert'); + if (opts.hideOnEmpty) el.classList.add('wt-gone'); + } paint(null, text, ''); }, ok() { - for (const el of contentOf()) el.classList.remove('wt-inert'); + for (const el of contentOf()) el.classList.remove('wt-inert', 'wt-gone'); if (cap) { cap.hidden = true; cap.textContent = ''; diff --git a/public/js/widget-glyphs.js b/public/js/widget-glyphs.js index 9cfd635..b5d33b7 100644 --- a/public/js/widget-glyphs.js +++ b/public/js/widget-glyphs.js @@ -1,5 +1,5 @@ // @ts-check -import { iconSvg } from '/js/icon-set.js?v=34af798f'; +import { iconSvg } from '/js/icon-set.js?v=628341d4'; /* Keys are the glyph names in widget.json files. Renaming one breaks that widget. */ /** @type {Record} */ diff --git a/public/js/widget-theme.js b/public/js/widget-theme.js index 295cd42..008611e 100644 --- a/public/js/widget-theme.js +++ b/public/js/widget-theme.js @@ -9,6 +9,8 @@ const root = document.documentElement; root.setAttribute('data-theme', dark ? 'dark' : 'light'); root.style.colorScheme = dark ? 'dark' : 'light'; + const zoom = frame && frame.getAttribute('data-zoom'); + if (zoom) root.style.zoom = zoom; /* A page with no background of its own shows the card through it. */ if (getComputedStyle(root).backgroundColor === 'rgba(0, 0, 0, 0)') root.style.backgroundColor = dark ? '#1C1C1E' : '#FFFFFF'; diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js index 7914af0..592d6a9 100644 --- a/public/js/widget-toolbox.js +++ b/public/js/widget-toolbox.js @@ -4,7 +4,7 @@ import { esc, html, setHtml } from '/js/html.js?v=c71f8903'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; import { jitter } from '/js/jitter.js?v=087a1fcf'; -import { errorState as _errorState, errorKind, errorCopy } from '/js/widget-error.js?v=7da9754b'; +import { errorState as _errorState, errorKind, errorCopy } from '/js/widget-error.js?v=3bfb691c'; import { setNumberLanguage } from '/js/format-number.js?v=349a741d'; export { esc, html, setHtml }; @@ -435,6 +435,18 @@ export async function loadStrings() { } } +/* The dashboard zooms the page. Rects and pointer positions come back zoomed, + CSS lengths do not. Divide a rect value by this before writing it to a style. + + @returns {number} */ +export function cssZoom() { + const z = /** @type {any} */ (document.documentElement).currentCSSZoom; + if (typeof z === 'number' && z > 0) return z; + const b = document.body; + const w = b && b.offsetWidth; + return (w && b.getBoundingClientRect().width / w) || 1; +} + /** @param {string} key @param {string} fallback @returns {string} */ export function wt(key, fallback) { const v = _own && _own[key]; @@ -506,7 +518,7 @@ export function poll(opts = {}) { const isEmpty = opts.isEmpty || (() => false); const doFetch = opts.fetch || (() => fetchData(opts.endpoint)); const custom = typeof opts.onError === 'function'; /* widget draws its own error UI */ - const ov = custom ? null : errorState({ root: opts.root || document.body, content: opts.content }); + const ov = custom ? null : errorState({ root: opts.root || document.body, content: opts.content, hideOnEmpty: true }); let lastOk = 0, fails = 0, everOk = false, diff --git a/public/js/yaml-lite.js b/public/js/yaml-lite.js index 445daad..038a824 100644 --- a/public/js/yaml-lite.js +++ b/public/js/yaml-lite.js @@ -1,5 +1,10 @@ // @ts-check +/** Thrown when merge keys copy more than MAX_MERGED_KEYS keys. */ +export class YamlTooLargeError extends Error {} + +const MAX_MERGED_KEYS = 50000; + /** Thrown for anything outside the supported subset, with the source line. */ export class YamlLiteError extends Error { /** @param {string} message @param {number} line */ @@ -13,7 +18,7 @@ export class YamlLiteError extends Error { /* The key stops at the first colon followed by a space or end of line, so "url: http://host:8080" splits once and keeps the port. */ -const KEY_RE = /^(?:(?:"((?:[^"\\]|\\.)*)")|(?:'((?:[^']|'')*)')|([^:#]+?))\s*:(?:\s+(.*))?$/; +const KEY_RE = /^(?:(?:"((?:[^"\\]|\\.)*)")|(?:'((?:[^']|'')*)')|([^:#\s](?:[^:#]*[^:#\s])?))\s*:(?:\s+(.*))?$/; /** @param {string} s */ const unescapeDouble = s => s.replace(/\\(["\\/nrt])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t' })[c] || c); @@ -260,7 +265,7 @@ function scan(text) { } /** @typedef {{ tolerant: boolean, errors: Array<{ line: number, reason: string }>, - anchors: Map }} Ctx */ + anchors: Map, merged: number }} Ctx */ /** Parse a block starting at `pos` whose lines are indented at least `indent`. @param {Line[]} lines @param {{ pos: number }} cur @param {number} indent @param {Ctx} ctx */ @@ -288,13 +293,16 @@ const blockAnchor = inline => { /** Fold a merge key's value into the mapping it was written in. Keys already set win, which is what a merge means. - @param {any} map @param {any} value @param {number} line */ -function merge(map, value, line) { + @param {any} map @param {any} value @param {number} line @param {Ctx} ctx */ +function merge(map, value, line, ctx) { const sources = Array.isArray(value) ? value : [value]; for (const src of sources) { if (!src || typeof src !== 'object' || Array.isArray(src)) throw new YamlLiteError('a merge key needs a mapping', line); - for (const k of Object.keys(src)) if (!(k in map)) map[k] = src[k]; + const keys = Object.keys(src); + ctx.merged += keys.length; + if (ctx.merged > MAX_MERGED_KEYS) throw new YamlTooLargeError(); + for (const k of keys) if (!(k in map)) map[k] = src[k]; } } @@ -389,7 +397,7 @@ function assign(map, m, line, lines, cur, indent, ctx, own) { /** Record the anchor this key carried, then store the value under the key. */ const set = value => { if (anchor) ctx.anchors.set(anchor, value); - if (key === '<<') merge(map, value, line); + if (key === '<<') merge(map, value, line, ctx); else map[key] = value; }; if (!anchor && inline !== undefined && inline.trim() !== '' && !/^#/.test(inline.trim())) { @@ -459,7 +467,7 @@ function mapping(lines, cur, indent, ctx) { /** @param {string} text @param {boolean} tolerant */ function run(text, tolerant) { /** @type {Ctx} */ - const ctx = { tolerant, errors: [], anchors: new Map() }; + const ctx = { tolerant, errors: [], anchors: new Map(), merged: 0 }; const lines = scan(String(text == null ? '' : text)); if (!lines.length) return { doc: null, errors: ctx.errors }; if (lines[0].indent !== 0) throw new YamlLiteError('unexpected indentation', lines[0].line); diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html index 7d1744e..507e56b 100644 --- a/public/widgets/backup/backup.html +++ b/public/widgets/backup/backup.html @@ -6,7 +6,7 @@ Backup - + - - + + - \ No newline at end of file + diff --git a/public/widgets/connections/connections-vpn.html b/public/widgets/connections/connections-vpn.html index 7e7979b..ef62574 100644 --- a/public/widgets/connections/connections-vpn.html +++ b/public/widgets/connections/connections-vpn.html @@ -6,7 +6,7 @@ Connections — VPN - +