diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index 9a8ee20..b1f0bcc 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,6 +1,13 @@ -import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=14b18b8c'; +import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=705ca896'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c7b5111'; +import { + loadLocalIcons, + resolveIcon, + iconChain, + cdnIconRef, + splitIconRef, + throughServer, +} from '/js/icons.js?v=5ac01d72'; import { state } from '/js/admin-state.js?v=af772a1b'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; @@ -11,7 +18,7 @@ import { failureIsMissingApiPath, nextActiveIndex, sameIconName, -} from '/js/admin-logic.js?v=fc7f0836'; +} from '/js/admin-logic.js?v=1cca36f5'; import { t } from '/js/i18n.js?v=ed7c5565'; import { toast, @@ -24,10 +31,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=36dc5cc1'; -import { createListbox } from '/js/listbox.js?v=23e87940'; +} from '/js/admin-shared.js?v=1c01be9a'; +import { createListbox } from '/js/listbox.js?v=f814170f'; import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=e80f4a68'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=4083ea3b'; 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=628341d4'; @@ -121,7 +128,7 @@ export function buildAppForm(body, item) {
- ${state.siurl ? html`` : html`${initial(item?.label)}`} + ${state.siurl ? html`` : html`${initial(item?.label)}`} @@ -667,12 +674,14 @@ function wireIcon() { const d = await apiGet(`/api/icons/search?q=${encodeURIComponent(q)}`); if (mine !== run) return; showIPRes(d.results || [], v); - } catch { + } catch (e) { if (mine !== run) return; /* No catalogue to confirm against, so the typed name is all that is left. */ updPrev(); - ipMessage(t('app.iconSearchFailed'), t('app.iconTryUpload')); - ipSay(t('app.iconSearchFailed')); + const demo = /** @type {{ code?: string }} */ (e)?.code === 'blocked.demo'; + const why = t(demo ? 'app.iconSearchDemo' : 'app.iconSearchFailed'); + ipMessage(why, demo ? '' : t('app.iconTryUpload')); + ipSay(why); } }; inp.oninput = () => { @@ -767,7 +776,7 @@ function ipRow(i, iconRef, name, source, urls = [], variants = []) { const img = document.createElement('img'); img.alt = ''; /* Ordered by the catalogue, whose first entry is the file it actually holds. */ - const srcs = [...urls, ...variants.flatMap(v => v.urls || [])].filter(Boolean); + const srcs = [...urls, ...variants.flatMap(v => v.urls || [])].filter(Boolean).map(throughServer); if (!srcs.length) srcs.push(...iconChain(iconRef)); let step = 0; img.src = srcs[0] || ''; @@ -857,7 +866,7 @@ function updPrev(explicit) { setInitialGlyph(p); return; } - const fallbacks = explicit?.length ? explicit : iconChain(state.siurl); + const fallbacks = explicit?.length ? explicit.map(throughServer) : iconChain(state.siurl); if (!fallbacks.length) { setInitialGlyph(p); return; diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index 1513ab2..cc91ba9 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=36dc5cc1'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=1c01be9a'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; 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=14b18b8c'; +import { el, inp as inpById, qa } from '/js/utils.js?v=705ca896'; import { blockingScreenFor } from '/js/config-recovery.js?v=85138697'; export async function checkAuth(onLogin) { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index 87b07e0..cd54810 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,8 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=36dc5cc1'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=1c01be9a'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=14b18b8c'; +import { qa, q } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; +import { createListbox } from '/js/listbox.js?v=f814170f'; 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']; @@ -147,9 +148,11 @@ export function renderColorControl( container, { value = '#0289ff', idPrefix, onChange, variant, swatchColors = CC_SWATCHES, label = t('common.color') } = {}, ) { - const top = variant === 'tile' ? TILE_KEYWORDS : variant === 'badge' ? ROLE_NAMES : []; + const styled = variant === 'tile'; + const styles = styled ? TILE_KEYWORDS : []; + const top = variant === 'badge' ? ROLE_NAMES : []; const hues = variant ? HUE_NAMES : []; - const isKw = v => typeof v === 'string' && (top.includes(v) || hues.includes(v)); + const isKw = v => typeof v === 'string' && (styles.includes(v) || top.includes(v) || hues.includes(v)); const kwName = v => t(COLOR_KEYS[v]); const init = _hexToHsv(isKw(value) ? _swatchFill(value) || '#0289ff' : value) || { h: 212, s: 99, v: 100 }; const kwSwatch = v => @@ -160,13 +163,18 @@ export function renderColorControl( const wrap = document.createElement('div'); const slider = (name, cls, id, max, val, lo, hi) => html`
${name}
${raw(lo)}${raw(hi)}
`; - const hueRow = hues.length - ? html`
${t('color.system')}
${hues.map(kwSwatch)}
` - : ''; + const hueRow = !hues.length + ? '' + : styled + ? html`
${label}
${hues.map(kwSwatch)}${rainbow}
` + : html`
${t('color.system')}
${hues.map(kwSwatch)}
`; + const topRow = styled + ? html`
${t('color.style')}
` + : html`
${label}
${swatches}
`; setHtml( wrap, html` -
${label}
${swatches}
+ ${topRow}
${hueRow} ${slider(t('appearance.hue'), 'hsb-range hsb-hue', `${idPrefix}-h`, 360, init.h, _ccIco.hueLo, _ccIco.hueHi)} @@ -179,7 +187,7 @@ export function renderColorControl( 'unsafe-inline'. A swatch paints itself from its own value. */ for (const b of wrap.querySelectorAll('.cc-swatch[data-v]')) { const v = b.getAttribute('data-v') || ''; - const fill = v[0] === '#' ? v : v === 'auto' || v === 'clear' ? '' : _swatchFill(v); + const fill = v[0] === '#' ? v : _swatchFill(v); if (fill) /** @type {HTMLElement} */ (b).style.background = fill; } const rows = /** @type {HTMLElement[]} */ ([...wrap.children]); @@ -196,6 +204,33 @@ export function renderColorControl( container.appendChild(hidden); let mode = isKw(value) ? value : 'color'; let showTune = hues.includes(mode); + /* The colour a Style other than Color replaced, so choosing Color brings it back. */ + let resume = null; + const styleOf = () => (styles.includes(mode) ? mode : 'color'); + const styleBox = styled + ? createListbox({ + id: `${idPrefix}-style`, + label: t('color.style'), + options: [...styles, 'color'].map(v => ({ value: v, label: v === 'color' ? label : kwName(v) })), + value: styleOf(), + onChange: next => { + if (next !== 'color') { + if (!styles.includes(mode)) resume = { mode, hex: curHex(), pristine }; + mode = next; + showTune = false; + commit(); + return; + } + if (styles.includes(mode)) { + mode = resume?.mode ?? 'blue'; + setSliders(resume?.hex ?? _swatchFill('blue')); + } + showTune = true; + commit(resume?.pristine); + }, + }) + : null; + if (styleBox) rows[0].appendChild(styleBox.el); /* The first paint restores stored state, so it must not animate. */ let _painted = false; /* The value as it was stored. The sliders are integers, so reading a colour @@ -235,13 +270,15 @@ export function renderColorControl( b.classList.toggle('on', on); }); const rb = q('.cc-rainbow', container); - if (rb) rb.classList.toggle('on', showTune && (mode === 'color' || hues.includes(mode))); + if (rb) + rb.classList.toggle('on', styled ? mode === 'color' : showTune && (mode === 'color' || hues.includes(mode))); const sw = qa('.cc-swatch', container); const preset = sw.some(b => b !== rb && b.classList.contains('on')); sw.forEach(b => b.setAttribute('aria-pressed', String(b === rb ? mode === 'color' && !preset : b.classList.contains('on'))), ); - rb?.setAttribute('aria-expanded', String(showTune)); + if (!styled) rb?.setAttribute('aria-expanded', String(showTune)); + styleBox?.setValue(styleOf()); reveal(tune, showTune, !_painted); if (!codeRv.closest('.editing')) { codeRv.textContent = mode === 'color' ? hex : kwName(mode); @@ -250,8 +287,8 @@ export function renderColorControl( hidden.value = mode === 'color' ? (pristine ?? hex) : mode; _painted = true; } - const commit = () => { - pristine = null; + const commit = (keep = null) => { + pristine = keep; paint(); onChange?.(hidden.value); }; @@ -265,6 +302,11 @@ export function renderColorControl( qa('.cc-swatch', container).forEach(b => b.addEventListener('click', () => { const v = b.dataset.v || ''; + if (v === 'custom' && styled) { + mode = 'color'; + commit(); + return; + } if (v === 'custom') { showTune = !(showTune && (mode === 'color' || hues.includes(mode))); if (showTune && !hues.includes(mode)) mode = 'color'; diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js index 3b98413..cd139f4 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=5811620e'; -import { qa } from '/js/utils.js?v=14b18b8c'; +import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; +import { qa } from '/js/utils.js?v=705ca896'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/public/js/admin-list.js b/public/js/admin-list.js index 1e85441..00aff25 100644 --- a/public/js/admin-list.js +++ b/public/js/admin-list.js @@ -12,14 +12,14 @@ 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=5811620e'; -import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=b2422767'; -import { paintIcon } from '/js/admin-shared.js?v=36dc5cc1'; -import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=14b18b8c'; +import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; +import { reorderItems } from '/js/admin-logic.js?v=1cca36f5'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=8fa6fbae'; +import { paintIcon } from '/js/admin-shared.js?v=1c01be9a'; +import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=705ca896'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=8a6be17f'; +import { sizeLabel } from '/js/admin-widget-form.js?v=1cb745cf'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/public/js/admin-logic.js b/public/js/admin-logic.js index 1a1e529..50569c2 100644 --- a/public/js/admin-logic.js +++ b/public/js/admin-logic.js @@ -336,3 +336,13 @@ export function createDirtyTracker(read) { }, }; } + +/** What a single-choice picker shows as chosen. An empty string is a choice + only when an option carries it. + @param {unknown} value @param {{ value?: unknown, group?: unknown }[]} options + @returns {string[]} */ +export function singleChoice(value, options) { + if (value == null) return []; + const v = String(value); + return v !== '' || options.some(o => o.group == null && String(o.value) === '') ? [v] : []; +} diff --git a/public/js/admin-save-logic.js b/public/js/admin-save-logic.js index f6e4466..048b454 100644 --- a/public/js/admin-save-logic.js +++ b/public/js/admin-save-logic.js @@ -122,6 +122,25 @@ function sameData(a, b) { ); } +/** What restoring `incoming` over `current` does to the item list. Compare + against the server's export, not the editor's items: the editor keeps row + shapes and secrets the export does not. + @param {any[]} current @param {any[]} incoming + @returns {{ added: number, updated: number, deleted: number }} */ +export function importCounts(current, incoming) { + const cur = new Map(current.map(i => [i?.id, i])); + const inc = new Map(incoming.map(i => [i?.id, i])); + let added = 0, + updated = 0, + deleted = 0; + for (const [id, it] of inc) { + if (!cur.has(id)) added++; + else if (!sameData(cur.get(id), it)) updated++; + } + for (const id of cur.keys()) if (!inc.has(id)) deleted++; + return { added, updated, deleted }; +} + /** The settings a backup restores. The server owns `auth` and drops it from every write. @param {unknown} settings @returns {Record | null} */ diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index 8e7f53b..7fbea01 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=36dc5cc1'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=1c01be9a'; 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=ed7c5565'; @@ -9,13 +9,13 @@ import { needsCurrentPassword, createDirtyTracker, BLOCK, -} from '/js/admin-logic.js?v=fc7f0836'; +} from '/js/admin-logic.js?v=1cca36f5'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; -import { el, inp, setUserText } from '/js/utils.js?v=14b18b8c'; +import { el, inp, setUserText } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { serialWrites } from '/js/admin-save-logic.js?v=5811620e'; -import { renderColorControl } from '/js/admin-color-control.js?v=e80f4a68'; -import { BACKDROP } from '/js/background.js?v=0f4ca726'; +import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; +import { renderColorControl } from '/js/admin-color-control.js?v=4083ea3b'; +import { BACKDROP } from '/js/background.js?v=25428bf2'; import { ALLOWED_HOSTS_MAX, firstBadHost, @@ -33,6 +33,7 @@ let _srvTrack = null; /** @type {{ dirty: () => boolean, reset: (force?: boolean) => void } | null} */ let _bgTrack = null; let _savedWallpaperUrl = ''; +let _unsplashKeySet = false; /** The wallpaper link the server holds, for undoing a link that failed. */ export const savedWallpaperUrl = () => _savedWallpaperUrl; @@ -71,7 +72,7 @@ const readWallpaperForm = () => _val('bg-url-inp', 'bg-url'), _val('bg-fit'), _val('bg-color-val'), - _val('bg-apikey-inp', 'bg-apikey'), + _val('bg-apikey-inp'), ]); /** Disables `buttonId` while its section matches what was last saved. */ @@ -126,6 +127,32 @@ function syncSessionRows(now = false) { const canRevoke = on && _passwordSet; reveal(el('sec-revoke-wrap'), canRevoke, now); reveal(el('revoke-tip-wrap'), canRevoke, now); + syncHostOffer(now); +} + +function syncHostOffer(now = false) { + const here = hostnameOf(location.host); + const listed = parseHostList(inp('srv-hosts')?.value || '') || []; + const show = !!inp('sec-en')?.checked && _passwordSet && !!here && !isLocalAddress(here) && !listed.includes(here); + const name = el('hosts-add-name'); + if (name && here) setUserText(name, here); + reveal(el('hosts-add-wrap'), show, now); + reveal(el('hosts-add-tip-wrap'), show, now); +} + +function addCurrentHost() { + const here = hostnameOf(location.host); + const hostsEl = inp('srv-hosts'); + const list = parseHostList(hostsEl?.value || ''); + if (!hostsEl || !here || !list) return; + hostsEl.value = [...list, here].join(', '); + const valEl = el('ie-hosts-v'); + if (valEl) { + setUserText(valEl, hostsEl.value); + valEl.classList.remove('is-ph'); + } + syncHostOffer(); + /** @type {HTMLElement | null | undefined} */ (el('ie-hosts')?.querySelector('.pe'))?.focus(); } /* Rebuild on every load. The control takes its value at render time and has no @@ -170,22 +197,9 @@ export function loadSettings(c) { if (llEl) llEl.value = s.logLevel || 'info'; const langEl = inp('lang-sel'); if (langEl) langEl.value = s.language || 'en'; - /* The key itself is never included in /api/config. */ - const apiEl = inp('bg-apikey-inp') || inp('bg-apikey'); - if (apiEl) { - apiEl.placeholder = `●●●●●●●●●● (${t('common.configured')})`; - apiGet('/api/settings/unsplash-key') - .then(d => { - const vEl = el('ie-apikey-v'); - if (!d.configured) { - apiEl.placeholder = t('appearance.unsplashKeyPh'); - if (vEl) vEl.textContent = t('common.notSet'); - } else { - if (vEl) vEl.textContent = t('common.configured'); - } - }) - .catch(() => {}); - } + apiGet('/api/settings/unsplash-key') + .then(d => showUnsplashKeyState(!!d.configured)) + .catch(() => {}); const colEl = inp('bg-col'); if (colEl) colEl.value = bg.collection || ''; const urlEl = inp('bg-url'); @@ -291,6 +305,8 @@ export function loadSettings(c) { } }); secEnEl?.addEventListener('change', () => syncSessionRows()); + el('hosts-add')?.addEventListener('click', addCurrentHost); + inp('srv-hosts')?.addEventListener('blur', () => syncHostOffer()); _srvTrack = trackSave('srv-save', readServerForm); _bgTrack = trackSave('bg-save', readWallpaperForm); @@ -333,6 +349,32 @@ export function showWallpaperFile(url) { } } +const unsplashKeyLabel = () => t(_unsplashKeySet ? 'common.configured' : 'common.notSet'); + +function showUnsplashKeyState(set) { + _unsplashKeySet = set; + const vEl = el('ie-apikey-v'); + if (vEl) vEl.textContent = unsplashKeyLabel(); +} + +/** The field opens empty. A blank field keeps the stored key. */ +export function wireUnsplashKey() { + const keyInp = document.createElement('input'); + keyInp.id = 'bg-apikey-inp'; + keyInp.autocomplete = 'new-password'; + document.body.appendChild(keyInp); + const row = el('ie-apikey'); + if (!row) return; + wireInlineEdit(row, keyInp, { + type: 'password', + placeholder: () => t(_unsplashKeySet ? 'widgetCfg.replaceSecret' : 'appearance.unsplashKeyPh'), + fill: false, + render: (valEl, v) => { + valEl.textContent = v ? t('widgetCfg.newValueSet') : unsplashKeyLabel(); + }, + }); +} + export function showBgFields(type) { const host = el('bg-unsplash-fields')?.parentElement; swapContent(host, () => { @@ -408,8 +450,13 @@ async function saveWallpaper() { /* After the main config. GET /api/config strips the key, so a config write that follows would overwrite it with nothing. */ if (type === 'unsplash') { - const keyVal = (inp('bg-apikey-inp') || inp('bg-apikey'))?.value?.trim() || ''; - if (keyVal) await apiPost('/api/settings/unsplash-key', { apiKey: keyVal }); + const keyEl = inp('bg-apikey-inp'); + const keyVal = keyEl?.value?.trim() || ''; + if (keyEl && keyVal) { + await apiPost('/api/settings/unsplash-key', { apiKey: keyVal }); + keyEl.value = ''; + showUnsplashKeyState(true); + } } _bgTrack?.reset(); toast(t('toast.saved')); diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index 0f0706f..4fd10a1 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,10 +1,10 @@ /* 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=14b18b8c'; +import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=1cca36f5'; +import { el, isolate, q } from '/js/utils.js?v=705ca896'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=b0881957'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { iconChain } from '/js/icons.js?v=9c7b5111'; +import { iconChain } from '/js/icons.js?v=5ac01d72'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 19a5431..e1d45f9 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,11 +1,11 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=36dc5cc1'; -import { createListbox } from '/js/listbox.js?v=23e87940'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=191cb80f'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=1c01be9a'; +import { createListbox } from '/js/listbox.js?v=f814170f'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=1807c371'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=fc7f0836'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=1cca36f5'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { focusFirst, q, qi, qa } from '/js/utils.js?v=14b18b8c'; +import { focusFirst, q, qi, qa } from '/js/utils.js?v=705ca896'; 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 3eaf715..e2664fe 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,12 +1,13 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=dd5f148f'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=71f8a57a'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=f0da79cf'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5246ff39'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=5bdad01b'; -import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=411d6f37'; +import { resolveAdminSection } from '/js/admin-logic.js?v=1cca36f5'; import { afterImport, buildAppItem, claimFolderChildren, + importCounts, newItemId, restoreBody, restoresSettings, @@ -14,14 +15,15 @@ import { serialWrites, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=5811620e'; +} from '/js/admin-save-logic.js?v=5613a9fa'; import { loadSettings, savedWallpaperUrl, settingsDirty, showBgFields, showWallpaperFile, -} from '/js/admin-settings.js?v=d69636fc'; + wireUnsplashKey, +} from '/js/admin-settings.js?v=4b374391'; import { apiGet, apiPost, @@ -35,16 +37,16 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=36dc5cc1'; +} from '/js/admin-shared.js?v=1c01be9a'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=8a6be17f'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=1cb745cf'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=23e87940'; +import { createListbox } from '/js/listbox.js?v=f814170f'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; -import { loadLocalIcons } from '/js/icons.js?v=9c7b5111'; +import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; import { ensureSprite, iconSvg } from '/js/icon-set.js?v=628341d4'; import { clearSkipTls, @@ -55,7 +57,7 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=6ecd782f'; +} from '/js/import-foreign.js?v=3b635c16'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { @@ -80,8 +82,8 @@ import { storeGet, storeSet, tgt, -} from '/js/utils.js?v=14b18b8c'; -import { applyBackground, resolveBackground } from '/js/background.js?v=0f4ca726'; +} from '/js/utils.js?v=705ca896'; +import { applyBackground, resolveBackground } from '/js/background.js?v=25428bf2'; import { parseYamlTolerant, YamlLiteError, YamlTooLargeError } from '/js/yaml-lite.js?v=a42ef914'; ensureSprite(); @@ -788,10 +790,7 @@ function initAllInlineEdits() { ); } - const apiInp = document.createElement('input'); - apiInp.id = 'bg-apikey-inp'; - document.body.appendChild(apiInp); - initInlineEdit('ie-apikey', 'bg-apikey-inp', { placeholder: () => t('appearance.unsplashKeyPh') }); + wireUnsplashKey(); const colInp = document.createElement('input'); colInp.id = 'bg-col-inp'; @@ -1162,18 +1161,7 @@ el('imp').onchange = async e => { try { const d = JSON.parse(await f.text()); if (!d || !Array.isArray(d.items)) throw new ShownError(t('toast.importNotStackyard')); - const cur = new Map(state.items.map(i => [i.id, i])); - const inc = new Map(d.items.map(i => [i.id, i])); - let added = 0, - updated = 0, - deleted = 0; - for (const [id, it] of inc) { - if (!cur.has(id)) added++; - else if (JSON.stringify(cur.get(id)) !== JSON.stringify(it)) updated++; - } - for (const id of cur.keys()) { - if (!inc.has(id)) deleted++; - } + const { added, updated, deleted } = importCounts((await apiGet('/api/config/export')).items, d.items); const settingsToo = restoresSettings(d, await apiGet('/api/config')); if (added + updated + deleted === 0 && !settingsToo) { toast(t('toast.importNoChange')); diff --git a/public/js/background.js b/public/js/background.js index 10ca4f1..9551519 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=14b18b8c'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=705ca896'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 22ef102..75a1d93 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -1,4 +1,4 @@ -import { loadLocalIcons, iconChain } from '/js/icons.js?v=9c7b5111'; +import { loadLocalIcons, iconChain } from '/js/icons.js?v=5ac01d72'; import { WIDGET_HEIGHTS, WIDGET_DESIGN, @@ -28,9 +28,9 @@ import { storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=14b18b8c'; +} from '/js/utils.js?v=705ca896'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=97143da4'; +import { initSpotlight } from '/js/spotlight.js?v=ad660003'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; @@ -46,7 +46,7 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=6af083c8'; +} from '/js/ui.js?v=049178d3'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=5c72bd4c'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=23793736'; @@ -59,7 +59,7 @@ import { restorePage, wheelPager, } from '/js/dashboard-logic.js?v=6a29cedc'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=0f4ca726'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=25428bf2'; 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'; @@ -70,6 +70,12 @@ import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/ ensureSprite(); +if (!('GestureEvent' in window)) { + const viewport = document.querySelector('meta[name="viewport"]'); + if (viewport) + viewport.setAttribute('content', `${viewport.getAttribute('content')},interactive-widget=resizes-content`); +} + /* Recomputed, never stored: the window can cross the breakpoint after load. */ let MOB = isMobileLayout(); @@ -746,18 +752,16 @@ async function boot() { v => ({ v }), e => ({ e }), ); - const loadConfig = () => - settled(fetch('/api/config', { cache: 'no-store', signal: AbortSignal.timeout(BOOT_TIMEOUT_MS) })); - let configReq = loadConfig(); + const configReq = settled(fetch('/api/config', { cache: 'no-store', signal: AbortSignal.timeout(BOOT_TIMEOUT_MS) })); const loadWidgets = () => settled( fetch('/api/widgets', { cache: 'no-store' }).then(r => { - if (!r.ok) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }), ); let widgetsReq = loadWidgets(); - let iconsReq = loadLocalIcons(); + const iconsReq = loadLocalIcons(); let authData = null; try { @@ -779,11 +783,6 @@ async function boot() { window.location.href = '/admin/'; return; } - /* Only the sign-in check stores the first address. Requests sent before it - on a first visit by host name are refused. */ - if ((await configReq).v?.status === 403) configReq = loadConfig(); - if ((await widgetsReq).e?.status === 403) widgetsReq = loadWidgets(); - if ((await iconsReq) === 403) iconsReq = loadLocalIcons(); } catch { /* API down, handled below */ } diff --git a/public/js/icons.js b/public/js/icons.js index a56ed29..395493c 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -86,38 +86,53 @@ function cdnFileUrl(source, slug, ext) { return `${CDN_BASE[source]}/${ext}/${slug}.${ext}`; } +function proxiedCdnUrl(url) { + for (const source of SOURCES) { + const base = `${CDN_BASE[source]}/`; + if (!url.startsWith(base)) continue; + const rest = url.slice(base.length); + const svgOnly = source === 'simple' || source === 'lobe'; + const m = svgOnly ? /^([^/?#]+)\.(svg)$/.exec(rest) : /^(svg|png)\/([^/?#]+)\.\1$/.exec(rest); + if (!m) return ''; + const [slug, ext] = svgOnly ? [m[1], m[2]] : [m[2], m[1]]; + return `/api/icons/cdn?name=${encodeURIComponent(slug)}&ext=${ext}&source=${source}`; + } + return ''; +} + +/** Load catalogue files through the server. A direct load tells the CDN which + services this dashboard shows. + @param {string} url @returns {string} */ +export function throughServer(url) { + return (_proxy && proxiedCdnUrl(url)) || url; +} + export function iconChain(rawIcon) { if (!rawIcon) return []; const localUrl = resolveIcon(rawIcon); + if (rawIcon.startsWith('http://') || rawIcon.startsWith('https://')) { + const chain = localUrl && localUrl !== rawIcon ? [localUrl] : []; + chain.push(throughServer(rawIcon)); + return chain; + } const name = rawIcon .replace(/\.(svg|png)$/i, '') .split('/') .pop() .split('?')[0]; const dot = rawIcon.lastIndexOf('.'); - const explicitExt = !rawIcon.startsWith('http') && dot > 0 ? rawIcon.slice(dot + 1).toLowerCase() : ''; - const chain = []; - if (localUrl) chain.push(localUrl); - if (rawIcon.startsWith('http')) { - if (localUrl && rawIcon !== localUrl) chain.push(rawIcon); - if (!localUrl) chain.push(rawIcon); - } else { - const { source, slug } = splitIconRef(name); - const cdn = encodeURIComponent(cdnIconName(slug)); - const src = `&source=${source}`; - /* Through the API first, which holds the file, so the CDN is not told which - services this dashboard shows on every load. The direct link stays as the - fallback for a failed proxy fetch. */ - /* One request, not one per format. Hundreds of catalogue entries are png - only and hundreds have no png, and the server knows which from the - catalogue index it already holds. */ - if (_proxy && explicitExt) chain.push(`/api/icons/cdn?name=${cdn}&ext=${explicitExt}${src}`); - else if (_proxy) chain.push(`/api/icons/cdn?name=${cdn}${src}`); - for (const ext of ['svg', 'png']) { - if (explicitExt && explicitExt !== ext) continue; - const direct = cdnFileUrl(source, cdn, ext); - if (direct) chain.push(direct); - } + const explicitExt = dot > 0 ? rawIcon.slice(dot + 1).toLowerCase() : ''; + const chain = localUrl ? [localUrl] : []; + const { source, slug } = splitIconRef(name); + const cdn = encodeURIComponent(cdnIconName(slug)); + if (_proxy) { + chain.push(`/api/icons/cdn?name=${cdn}${explicitExt ? `&ext=${explicitExt}` : ''}&source=${source}`); + return chain; + } + for (const ext of ['svg', 'png']) { + if (explicitExt && explicitExt !== ext) continue; + const direct = cdnFileUrl(source, cdn, ext); + if (direct) chain.push(direct); } return chain; } diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js index 423cba8..8e9faec 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=5811620e'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=5613a9fa'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ diff --git a/public/js/listbox.js b/public/js/listbox.js index b936845..67bc726 100644 --- a/public/js/listbox.js +++ b/public/js/listbox.js @@ -6,11 +6,11 @@ Do not use popover. The top layer makes iOS collapse its browser toolbar, which exposes a strip of page canvas and misplaces the menu. */ -import { nextActiveIndex } from '/js/admin-logic.js?v=fc7f0836'; +import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=1cca36f5'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=14b18b8c'; +import { qa } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); @@ -81,9 +81,7 @@ export function createListbox( } = /** @type {any} */ ({}), ) { let opts = options.slice(); - let chosen = multiple - ? new Set((Array.isArray(value) ? value : []).map(String)) - : new Set(value == null || value === '' ? [] : [String(value)]); + let chosen = multiple ? new Set((Array.isArray(value) ? value : []).map(String)) : new Set(singleChoice(value, opts)); /* The list is not a descendant of its button, so the two are tied by id. */ const listId = id ? `${id}-list` : `lb-${++seq}`; @@ -331,17 +329,13 @@ export function createListbox( list, getValue, setValue(v) { - chosen = multiple - ? new Set((Array.isArray(v) ? v : []).map(String)) - : new Set(v == null || v === '' ? [] : [String(v)]); + chosen = multiple ? new Set((Array.isArray(v) ? v : []).map(String)) : new Set(singleChoice(v, opts)); paint(); }, setOptions(next, v) { opts = (next || []).slice(); if (v !== undefined) { - chosen = multiple - ? new Set((Array.isArray(v) ? v : []).map(String)) - : new Set(v == null || v === '' ? [] : [String(v)]); + chosen = multiple ? new Set((Array.isArray(v) ? v : []).map(String)) : new Set(singleChoice(v, opts)); } else if (!multiple) { /* A refreshed list may no longer carry what was chosen. */ const still = [...chosen].filter(c => opts.some(o => String(o.value) === c)); diff --git a/public/js/saved-row.js b/public/js/saved-row.js new file mode 100644 index 0000000..7383bb7 --- /dev/null +++ b/public/js/saved-row.js @@ -0,0 +1,6 @@ +/* The server requires this file directly. Keep it free of the DOM, of window + and of imports. */ + +/* A group row's index in the saved config, sent by the editor. The server keeps + a stored secret only with the row it was saved for. */ +export const SAVED_ROW = 'savedRow'; diff --git a/public/js/spotlight.js b/public/js/spotlight.js index c5e8735..209d6ef 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=14b18b8c'; +import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=705ca896'; import { t } from '/js/i18n.js?v=ed7c5565'; /* Attached to the window so a re-open can undo the previous one. */ diff --git a/public/js/ui.js b/public/js/ui.js index b8b3139..8acfe97 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,4 +1,4 @@ -import { iconChain } from '/js/icons.js?v=9c7b5111'; +import { iconChain } from '/js/icons.js?v=5ac01d72'; import { widgetSrc, cardPreset, fixedAppearance, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=b0e8f76d'; import { mk, @@ -16,7 +16,7 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=14b18b8c'; +} from '/js/utils.js?v=705ca896'; import { t, currentLang } from '/js/i18n.js?v=ed7c5565'; 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 03e9914..404721d 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,4 +1,4 @@ -import { iconChain } from '/js/icons.js?v=9c7b5111'; +import { iconChain } from '/js/icons.js?v=5ac01d72'; import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; import { SETTINGS_ICON, SETTINGS_ICON_LIGHT } from '/js/settings-icon.js?v=4079b66a'; import { mkGlassRim } from '/js/glass-rim.js?v=3faec233'; @@ -410,8 +410,15 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla const refreshInterval = Number(o.refreshInterval) || 0; if (refreshInterval >= 250) { const base = refreshInterval; + /* Not ifr.src alone: each assignment adds an entry to the tab's Back + history. */ const reload = () => { - ifr.src = src + (src.includes('?') ? '&' : '?') + '_r=' + Date.now(); + const next = src + (src.includes('?') ? '&' : '?') + '_r=' + Date.now(); + try { + ifr.contentWindow.location.replace(next); + } catch { + ifr.src = next; + } }; const jit = () => Math.round(base * (1 + (Math.random() * 2 - 1) * 0.15)); /* The handle is reassigned every tick. The cleanup must read the current diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index a79a23a..f405f06 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=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=36dc5cc1'; -import { createListbox } from '/js/listbox.js?v=23e87940'; -import { renderColorControl } from '/js/admin-color-control.js?v=e80f4a68'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=1c01be9a'; +import { createListbox } from '/js/listbox.js?v=f814170f'; +import { renderColorControl } from '/js/admin-color-control.js?v=4083ea3b'; import { seedCarried, applyOptionSet, @@ -13,10 +13,11 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=fc7f0836'; +} from '/js/admin-logic.js?v=1cca36f5'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; -import { qi } from '/js/utils.js?v=14b18b8c'; +import { qi } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; +import { SAVED_ROW } from '/js/saved-row.js?v=44039df6'; const PE = ''; @@ -579,8 +580,11 @@ function _object(field, value, ctx) { function _group(field, rows, size, ctx) { const { min, max } = groupBounds(field, size); const subFields = Array.isArray(field.fields) ? field.fields : []; - let data = Array.isArray(rows) ? rows.map(r => Object.assign({}, r)) : []; - while (data.length < min) data.push({}); + const secretKeys = subFields.filter(sf => sf.type === 'secret').map(sf => sf.key); + const tracked = secretKeys.length > 0; + const newRow = () => (tracked ? { [SAVED_ROW]: null } : {}); + let data = Array.isArray(rows) ? rows.map((r, i) => (tracked ? { ...r, [SAVED_ROW]: i } : { ...r })) : []; + while (data.length < min) data.push(newRow()); if (data.length > max) data.length = max; const wrap = document.createElement('div'); @@ -654,14 +658,19 @@ function _group(field, rows, size, ctx) { } function captureCurrent() { - data = rowBuilt.map(built => - collectFieldValues(built.map(b => ({ field: b.field, visible: _visible(b), kv: b.get() }))), - ); + data = rowBuilt.map((built, i) => { + const row = collectFieldValues(built.map(b => ({ field: b.field, visible: _visible(b), kv: b.get() }))); + const before = data[i] || {}; + if (!tracked) return row; + for (const k of secretKeys) if (!Object.hasOwn(row, k) && before[k + 'Set'] === true) row[k + 'Set'] = true; + row[SAVED_ROW] = before[SAVED_ROW] ?? null; + return row; + }); } addBtn.onclick = () => { captureCurrent(); - data.push({}); + data.push(newRow()); render(); }; render(); diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js index e0b6d61..e5b5e77 100644 --- a/public/js/widget-toolbox.js +++ b/public/js/widget-toolbox.js @@ -92,6 +92,9 @@ export function widgetId() { return _params.get('id') || ''; } +const PENDING_RETRY_MS = 1000; +const PENDING_TRIES = 20; + export async function fetchData(endpoint, opts = {}) { const id = widgetId(); const p = new URLSearchParams(); @@ -99,7 +102,15 @@ export async function fetchData(endpoint, opts = {}) { for (const [k, v] of Object.entries(opts.params || {})) p.set(k, String(v)); const query = p.toString(); const qs = query ? '?' + query : ''; - const r = await fetch(`/api/widget-data/${encodeURIComponent(id)}${qs}`, { cache: 'no-store', signal: opts.signal }); + let r; + for (let tries = 0; ; tries++) { + r = await fetch(`/api/widget-data/${encodeURIComponent(id)}${qs}`, { cache: 'no-store', signal: opts.signal }); + if (r.status !== 202) break; + if (tries >= PENDING_TRIES) { + throw Object.assign(new Error('The service did not respond in time.'), { status: 202, kind: 'timeout' }); + } + await new Promise(resolve => setTimeout(resolve, PENDING_RETRY_MS)); + } if (!r.ok) { const d = await r.json().catch(() => ({})); const e = /** @type {Error & { status?: number, kind?: string }} */ (new Error(d.error || 'HTTP ' + r.status)); @@ -107,7 +118,19 @@ export async function fetchData(endpoint, opts = {}) { if (typeof d.kind === 'string') e.kind = d.kind; throw e; } - return r.json(); + const data = await r.json(); + const age = Number(r.headers.get('Age')); + if (data && typeof data === 'object') _readAt.set(data, Date.now() - (age > 0 ? age * 1000 : 0)); + return data; +} + +/** @type {WeakMap} */ +const _readAt = new WeakMap(); + +/** When the service produced data that fetchData returned. + @param {unknown} data @returns {number} */ +export function readingTime(data) { + return (data && typeof data === 'object' && _readAt.get(data)) || Date.now(); } export function openUrl(href) { @@ -575,6 +598,37 @@ export function errorLine(err) { return _t(key, text); } +/** Calls onChange(true) while el is on screen in a visible tab and + onChange(false) when it leaves. + @param {Element} el @param {(shown: boolean) => void} onChange + @returns {{ stop(): void }} */ +export function whileOnScreen(el, onChange) { + const observable = typeof IntersectionObserver === 'function'; + let inView = !observable; + let shown = null; + const update = () => { + const now = inView && !document.hidden; + if (now === shown) return; + shown = now; + onChange(now); + }; + const io = observable + ? new IntersectionObserver(entries => { + inView = entries[entries.length - 1].isIntersecting; + update(); + }) + : null; + if (io) io.observe(el); + else update(); + document.addEventListener('visibilitychange', update); + return { + stop() { + if (io) io.disconnect(); + document.removeEventListener('visibilitychange', update); + }, + }; +} + /* Widgets on a page the user has swiped away from keep running: the dashboard mounts every page at once. It multiplies their poll interval through window.__setPollRate, which the dashboard calls on each frame. */ @@ -593,6 +647,14 @@ if (typeof window !== 'undefined') /** @type {any} */ (window).__setPollRate = s const SLOW_POLL_MS = 300000; +function asInternal(e) { + if (e && typeof e === 'object' && !Object.isFrozen(e)) { + if (typeof e.kind !== 'string') e.kind = 'internal'; + return e; + } + return Object.assign(new Error(String(e)), { kind: 'internal' }); +} + export function poll(opts = {}) { const intervalFor = d => (typeof opts.interval === 'function' ? opts.interval(d) : opts.interval) || 30000; /* A slow widget that waits for a second failure shows data two intervals old @@ -615,13 +677,11 @@ export function poll(opts = {}) { async function tick() { if (stopped) return; lastTick = Date.now(); + let fetched = false; try { const data = await doFetch(); + fetched = true; if (stopped) return; - fails = 0; - lastOk = Date.now(); - everOk = true; - lastData = data; if (isEmpty(data)) { if (ov) ov.empty(opts.emptyText || _t('noData', 'No data')); else opts.onEmpty && opts.onEmpty(data); @@ -629,8 +689,13 @@ export function poll(opts = {}) { if (ov) ov.ok(); opts.render && opts.render(data); } - } catch (e) { + fails = 0; + lastOk = readingTime(data); + everOk = true; + lastData = data; + } catch (caught) { if (stopped) return; + const e = fetched ? asInternal(caught) : caught; fails++; const stale = fails >= staleAfter(); if (custom) opts.onError({ error: e, everOk, stale, since: lastOk ? sinceLabel(lastOk) : '' }); diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html index 0ca7075..ba4a3b3 100644 --- a/public/widgets/backup/backup.html +++ b/public/widgets/backup/backup.html @@ -124,7 +124,7 @@