diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index fc039ab..9a8f92b 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,8 +1,9 @@ -import { clr, el, focusFirst, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=9a9bfb54'; +import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=fdc0243f'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c8c550c'; +import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c7b5111'; 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=349a741d'; import { isDockBlocked, clearsStoredSecret, @@ -11,9 +12,10 @@ import { nextActiveIndex, sameIconName, } from '/js/admin-logic.js?v=fc7f0836'; -import { t } from '/js/i18n.js?v=1f1ea9c1'; +import { t } from '/js/i18n.js?v=5579776a'; import { toast, + errorText, responseError, apiGet, apiPost, @@ -22,11 +24,11 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=f5551857'; -import { createListbox } from '/js/listbox.js?v=30c1b9d1'; +} from '/js/admin-shared.js?v=81ab2f92'; +import { createListbox } from '/js/listbox.js?v=11c869c4'; import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=233683ad'; -import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=f82486b2'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=426a5709'; +import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=61f73e4d'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; @@ -119,7 +121,7 @@ export function buildAppForm(body, item) {
- ${state.siurl ? html`` : html`${(item?.label || '?')[0]?.toUpperCase() || '?'}`} + ${state.siurl ? html`` : html`${initial(item?.label)}`} @@ -134,7 +136,7 @@ export function buildAppForm(body, item) {
${t('app.showInDock')}${tog('f-dock', !!item?.dock, t('app.showInDock'))}
- ${dockBlocked ? html`

${t('app.dockFull', { max: DOCK_MAX })}

` : ''} + ${dockBlocked ? html`

${t('app.dockFull', { max: formatNumber(DOCK_MAX) })}

` : ''}

${t('app.badge')}

@@ -169,7 +171,7 @@ export function buildAppForm(body, item) {
${t('app.authentication')}${tog('auth-en', !!(act.params || act.headers), t('app.authentication'))}
-
${t('app.addToUrl')} (query params)
+
${t('app.addToUrl')} ${t('app.queryParams')}
${t('app.addToHeader')}
@@ -229,8 +231,8 @@ export function buildAppForm(body, item) { }); state._bpar = normKvRows(act.params); state._bhdr = normKvRows(act.headers); - renderKvRows(el('bpar-rows'), state._bpar, 'key=value'); - renderKvRows(el('bhdr-rows'), state._bhdr, 'X-Api-Key=…'); + renderKvRows(el('bpar-rows'), state._bpar, t('app.value')); + renderKvRows(el('bhdr-rows'), state._bhdr, '…'); wireIcon(); if (state.siurl) updPrev(); @@ -366,7 +368,7 @@ function renderActLabels(host) { hdr.dataset.idx = String(i); setHtml( hdr, - html`${t('app.labelN', { n: i + 1 })}`, + html`${t('app.labelN', { n: formatNumber(i + 1) })}`, ); const ctl = document.createElement('span'); ctl.className = 'albl-ctl'; @@ -727,7 +729,7 @@ function wireIcon() { const form = new FormData(); form.append('icon', file, file.name); const r = await fetch('/api/icons/upload', { method: 'POST', body: form }); - if (!r.ok) throw new Error(await responseError(r)); + if (!r.ok) throw await responseError(r); const d = await r.json(); await loadLocalIcons(); state.siurl = d.filename; @@ -736,9 +738,9 @@ function wireIcon() { renderIconVariants([]); ipClose(); updPrev(); - toast(t('toast.uploaded', { name: d.filename })); + toast(t('toast.uploaded', { name: isolate(d.filename) })); } catch (e) { - toast(t('toast.uploadFailed', { err: e.message }), 'err'); + toast(t('toast.uploadFailed', { err: errorText(e) }), 'err'); } finally { upBtn.textContent = origText; upInput.value = ''; @@ -840,7 +842,7 @@ function showIPRes(list, rawInput) { function setInitialGlyph(p) { const l = inpById('f-lbl')?.value || '?'; const s = document.createElement('span'); - s.textContent = (l[0] || '?').toUpperCase(); + s.textContent = initial(l); p.replaceChildren(s); } /* Several attempts are in flight at once and the half-typed ones finish last. @@ -887,11 +889,10 @@ async function testPing() { const skipTls = inpById('f-skip-tls')?.checked || false; try { const r = await apiPost('/api/ping', { url, skipTls }); - st.textContent = r.ok - ? '✓ ' + t('app.reachable', { status: r.status }) - : '✗ ' + t('app.httpError', { status: r.status }); + if (r.ok) st.textContent = '✓ ' + t('app.reachable', { status: r.status }); + else st.textContent = '✗ ' + (r.status ? t('app.httpError', { status: r.status }) : errorText(r)); } catch (e) { - st.textContent = '✗ ' + e.message; + st.textContent = '✗ ' + errorText(e); } } @@ -941,12 +942,10 @@ function renderKvRows(host, rows, ph) { host.appendChild(add); } -const defaultValuePlaceholder = ph => ph.split('=')[1] || 'value'; - function kvRowEl(host, rows, row, ph) { const rowEl = document.createElement('div'); rowEl.className = 'kv-row'; - const valPh = row.secret && row.valueSet && row.value === '' ? 'Configured' : defaultValuePlaceholder(ph); + const valPh = row.secret && row.valueSet && row.value === '' ? t('common.configured') : ph; setHtml( rowEl, html` @@ -975,7 +974,7 @@ function kvRowEl(host, rows, row, ph) { if (clearsStoredSecret(row, cEl.checked)) { row.valueSet = false; vEl.value = ''; - vEl.placeholder = defaultValuePlaceholder(ph); + vEl.placeholder = ph; } }; dEl.onclick = () => { diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index 0acf19c..34491d2 100644 --- a/public/js/admin-auth.js +++ b/public/js/admin-auth.js @@ -1,13 +1,16 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=f5551857'; -import { t } from '/js/i18n.js?v=1f1ea9c1'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=81ab2f92'; +import { initI18n, t } from '/js/i18n.js?v=5579776a'; +import { loginErrorKey } from '/js/admin-error.js?v=61f73e4d'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { el, inp as inpById, qa } from '/js/utils.js?v=9a9bfb54'; -import { blockingScreenFor } from '/js/config-recovery.js?v=706fc9a7'; +import { el, inp as inpById, qa } from '/js/utils.js?v=fdc0243f'; +import { blockingScreenFor } from '/js/config-recovery.js?v=dbe542e1'; export async function checkAuth(onLogin) { try { const d = await apiGet('/api/auth/check'); if (!d.enabled || d.authenticated) return true; + await initI18n(d.language || 'en'); + document.title = t('nav.pageTitle'); showLoginScreen(onLogin); return false; } catch (e) { @@ -67,7 +70,7 @@ function showLoginScreen(onLogin) { onLogin?.(); } catch (e) { if (err) { - err.textContent = e.message || t('login.incorrect'); + err.textContent = t(loginErrorKey(e)); err.style.display = 'block'; } if (pw) { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index 86e2b2c..d9a8e39 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=f5551857'; -import { t } from '/js/i18n.js?v=1f1ea9c1'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=81ab2f92'; +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=9a9bfb54'; +import { qa, q } from '/js/utils.js?v=fdc0243f'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js index 829c971..d854315 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=8389782f'; -import { qa } from '/js/utils.js?v=9a9bfb54'; +import { snapshotItems } from '/js/admin-save-logic.js?v=ae65f9c8'; +import { qa } from '/js/utils.js?v=fdc0243f'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/public/js/admin-error.js b/public/js/admin-error.js index 12b352f..b369c8c 100644 --- a/public/js/admin-error.js +++ b/public/js/admin-error.js @@ -23,6 +23,28 @@ const BY_CODE = Object.freeze({ 'upstream.redirect': 'adminError.redirect', 'network.tls-ignored': 'adminError.tlsIgnored', 'network.tls-untrusted': 'adminError.tlsUntrusted', + 'blocked.read-only': 'adminError.readOnly', + 'blocked.rate-limit': 'toast.tooManyAttempts', + 'invalid.too-large': 'toast.imageTooLarge', + 'invalid.file-type': 'adminError.fileType', + 'invalid.url': 'adminError.invalidUrl', + 'upstream.refused': 'adminError.socketRefused', + 'upstream.not-docker': 'adminError.notSocketProxy', + 'blocked.unresolved': 'adminError.unreachable', + 'invalid.duplicate-id': 'adminError.duplicateId', + 'invalid.missing-children': 'adminError.missingChildren', + 'invalid.unsafe-link': 'adminError.unsafeLink', + 'invalid.dock-full': 'app.dockFull', + 'network.refused': 'adminError.refused', + 'network.not-found': 'adminError.hostNotFound', + 'network.unreachable': 'adminError.hostUnreachable', + 'network.reset': 'adminError.connectionReset', + 'network.not-http': 'adminError.notHttp', + 'network.self-signed': 'adminError.selfSigned', + 'network.self-signed-public': 'adminError.selfSignedPublic', + 'network.tls-expired': 'adminError.tlsExpired', + 'timeout.no-answer': 'adminError.timedOut', + 'blocked.demo': 'adminError.demoOutbound', }); const BY_KIND = Object.freeze({ @@ -52,16 +74,43 @@ export function readError(e) { return { kind, code, detail }; } +/* Placeholders only. A value is an id, a status or a limit, never a sentence. */ +/** @param {Record | null} detail @returns {Record | null} */ +function detailVars(detail) { + if (!detail) return null; + /** @type {Record} */ + const vars = {}; + for (const [k, v] of Object.entries(detail)) { + if (k === 'reason') continue; + if (typeof v === 'number' || (typeof v === 'string' && v.length <= 200)) vars[k] = v; + } + return Object.keys(vars).length ? vars : null; +} + function adviceFor(read) { const { kind, code, detail } = read; const status = detail && typeof detail.status === 'number' ? detail.status : null; if (code === 'upstream.status' && status !== null) return { key: statusKey(status), vars: { status } }; if (BY_CODE[code]) { - return status !== null ? { key: BY_CODE[code], vars: { status } } : { key: BY_CODE[code] }; + const vars = detailVars(detail); + return vars ? { key: BY_CODE[code], vars } : { key: BY_CODE[code] }; } return { key: BY_KIND[kind] || BY_KIND[KIND.INTERNAL] }; } +/** What to show for a failed request to the API's own routes. + @param {unknown} e @returns {{ key: string, vars?: Record }} */ +export function errorAdvice(e) { + return adviceFor(readError(e)); +} + +/* The probe's hint already says why nothing answered and what to change. */ +/** @param {unknown} probe @returns {{ key: string, vars?: Record }} */ +export function socketProbeAdvice(probe) { + const read = readError(probe); + return read.code === KIND.NETWORK ? { key: 'adminError.noConnection' } : adviceFor(read); +} + export function badgeErrorAdvice(e) { const read = readError(e); const { kind, code, detail } = read; @@ -83,3 +132,20 @@ export function optionsErrorAdvice(e) { const { tone, key, vars, code } = badgeErrorAdvice(e); return vars ? { tone, code, key, vars } : { tone, code, key }; } + +/** @param {unknown} e @returns {string} */ +export function loginErrorKey(e) { + const { kind, code } = readError(e); + if (code === 'blocked.rate-limit') return 'toast.tooManyAttempts'; + if (kind === KIND.AUTH) return 'login.incorrect'; + return 'adminError.genericInternal'; +} + +/* A first-run password is refused as auth or as changed when another tab or + device set one first. */ +/** @param {unknown} e @returns {string} */ +export function setupErrorKey(e) { + const { kind, code } = readError(e); + if (kind === KIND.AUTH || code === 'invalid.password-changed') return 'toast.passwordChangedElsewhere'; + return 'setup.failed'; +} diff --git a/public/js/admin-list.js b/public/js/admin-list.js index 306e55b..cf31cc1 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=8389782f'; +import { snapshotItems } from '/js/admin-save-logic.js?v=ae65f9c8'; import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=444456dd'; -import { paintIcon } from '/js/admin-shared.js?v=f5551857'; -import { clr, el, focusFirst, qa, setUserText } from '/js/utils.js?v=9a9bfb54'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=5e9c57c7'; +import { paintIcon } from '/js/admin-shared.js?v=81ab2f92'; +import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=fdc0243f'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { t } from '/js/i18n.js?v=1f1ea9c1'; -import { sizeLabel } from '/js/admin-widget-form.js?v=53ceafd3'; +import { t } from '/js/i18n.js?v=5579776a'; +import { sizeLabel } from '/js/admin-widget-form.js?v=b05766fe'; import { widgetGlyph } from '/js/widget-glyphs.js?v=648cc374'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; @@ -61,9 +61,6 @@ function moveRow(item, dir, opts = {}) { export function mkRow(item, idx, { indent = false, childIdx = null, folderId = null } = {}) { const row = document.createElement('div'); row.className = 'row drow'; - if (indent) - row.style.cssText = - 'padding-left:28px;background:rgba(255,255,255,.02);border-left:2px solid var(--bd);margin-left:8px;border-radius:0 var(--rs) var(--rs) 0;'; const _filtering = !!(filter.q || filter.type !== 'all'); row.draggable = !_filtering; row.dataset.itemId = item.id; @@ -102,8 +99,8 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n const glyph = widgetGlyph(state._widgetReg?.[item.widgetType]?.glyph); ico.appendChild(svgNode(glyph || SIZE_ICONS[item.widgetSize] || SIZE_ICONS.medium)); } else if (item.iconUrl) { - paintIcon(ico, item.iconUrl, (item.label || '?')[0].toUpperCase(), 'width:28px;height:28px;object-fit:contain;'); - } else ico.textContent = (item.label || item.id || '?')[0].toUpperCase(); + paintIcon(ico, item.iconUrl, initial(item.label), 'width:28px;height:28px;object-fit:contain;'); + } else ico.textContent = initial(item.label || item.id); const inf = document.createElement('div'); inf.className = 'rinf'; const isFolderRow = item.type === 'folder'; @@ -121,7 +118,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n chevron.textContent = '▼'; chevron.style.transform = collapsed ? 'rotate(-90deg)' : 'rotate(0deg)'; chevron.id = 'chev-' + item.id; - nm.append(chevron, document.createTextNode(item.label)); + nm.append(chevron, setUserText(document.createElement('span'), item.label)); nm.onclick = e => { e.stopPropagation(); if (collapsedFolders.has(item.id)) { @@ -138,7 +135,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n mt.className = 'rmt'; if (item.type === 'widget') { const wt = item.widgetType || 'custom'; - const wtLabel = state._widgetReg?.[wt]?.label || 'Custom'; + const wtLabel = state._widgetReg?.[wt]?.label || t('widgetCfg.typeCustom'); mt.textContent = t('widgetCfg.meta', { type: wtLabel, size: sizeLabel(item.widgetSize || 'medium') }); } else if (item.type === 'folder') mt.textContent = t('folder.appsCount', { count: (item.children || []).length }); else if (item.system === 'settings') mt.textContent = t('home.opensSettings'); diff --git a/public/js/admin-save-logic.js b/public/js/admin-save-logic.js index c435068..8b2de7e 100644 --- a/public/js/admin-save-logic.js +++ b/public/js/admin-save-logic.js @@ -47,44 +47,82 @@ export function snapshotItems(items) { return Array.isArray(items) ? structuredClone(items) : []; } -/** Run `write` and undo the local change when it did not reach the server. - Without this the list shows a dashboard the server does not have. `write` - reports failure by resolving false; a throw is re-raised after the restore. - `superseded` reports a later change still waiting to save. Its write carries - this change too, so restoring here would drop the later change. +/** Saves that undo the local change when it did not reach the server. Without + this the list shows a dashboard the server does not have. `write` reports + failure by resolving false; a throw is re-raised after the restore. A failed + save with another one waiting leaves the list alone: the later write carries + this change too. It hands its snapshot to that save, which restores it if it + also fails. + + Every writer calls `landed` with the list the server took. A failed save + restores that list, not its snapshot, when a write landed after the + snapshot was taken: the snapshot lacks a change the server has, and the + next save would delete it. @template T - @param {{ write: () => Promise, snapshot: T, - restore: (snapshot: T) => void, superseded?: () => boolean }} opts - @returns {Promise} */ -export async function saveWithRevert({ write, snapshot, restore, superseded = () => false }) { - let ok = false; - try { - ok = (await write()) !== false; - } finally { - if (!ok && !superseded()) restore(snapshot); + @param {{ write: () => Promise, restore: (snapshot: T) => void }} opts + @returns {((snapshot: T) => Promise) & { landed: (sent: T) => void }} */ +export function revertingSaves({ write, restore }) { + let waiting = 0; + let landings = 0; + /** @type {T | undefined} */ + let lastLanded; + /** @type {{ seen: number, snapshot: T } | null} */ + let carried = null; + const save = async (/** @type {T} */ snapshot) => { + const seen = landings; + waiting++; + let ok = false; + try { + ok = (await write()) !== false; + } finally { + waiting--; + const before = carried || { seen, snapshot }; + carried = null; + if (!ok) { + if (waiting > 0) carried = before; + else restore(landings > before.seen ? /** @type {T} */ (lastLanded) : before.snapshot); + } + } + return ok; + }; + save.landed = (/** @type {T} */ sent) => { + landings++; + lastLanded = sent; + }; + return save; +} + +/** What a page holds once its import lands. A list change made while the + import ran is not in `current`; dropping it lets its queued save send the + list without it. A page whose list is stale takes the server list, or its + next save deletes what another tab added, and a change queued on the stale + list must be refused. + + @param {{ local: any[], saved: any[], current: any[], serverItems: string, newItems: any[] }} lists + `saved` is the list this page last sent, `current` the list the import + read, `serverItems` the server list this page last saw, as JSON. + @returns {{ items: any[], saved: any[], stale: boolean }} */ +export function afterImport({ local, saved, current, serverItems, newItems }) { + if (JSON.stringify(current) !== serverItems) { + return { items: [...current, ...newItems], saved: [...current, ...newItems], stale: true }; } - return ok; + return { items: [...local, ...newItems], saved: [...saved, ...newItems], stale: false }; } /** Run writes one at a time, in the order asked. A write asked for while another runs waits for it instead of being dropped. - @returns {{ run: (write: () => Promise) => Promise, pending: () => number }} */ + @returns {{ run: (write: () => Promise) => Promise }} */ export function serialWrites() { /** @type {Promise} */ let tail = Promise.resolve(); - let pending = 0; return { run(write) { - pending++; - const p = tail.then(write).finally(() => { - pending--; - }); + const p = tail.then(write); tail = p.catch(() => {}); return p; }, - pending: () => pending, }; } diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index 7bc50b2..ffec9b3 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,6 +1,7 @@ -import { toast, apiGet, apiPost, reveal, swapContent } from '/js/admin-shared.js?v=f5551857'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=81ab2f92'; +import { socketProbeAdvice } from '/js/admin-error.js?v=61f73e4d'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { t } from '/js/i18n.js?v=1f1ea9c1'; +import { t } from '/js/i18n.js?v=5579776a'; import { shouldWritePassword, settingsSaveBlocker, @@ -10,11 +11,18 @@ 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=9a9bfb54'; -import { serialWrites } from '/js/admin-save-logic.js?v=8389782f'; -import { renderColorControl } from '/js/admin-color-control.js?v=233683ad'; -import { BACKDROP } from '/js/background.js?v=43a04bdb'; -import { firstBadHost, hostnameOf, isLocalAddress, parseHostList } from '/js/host-names.js?v=842f96ca'; +import { el, inp, setUserText } from '/js/utils.js?v=fdc0243f'; +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=426a5709'; +import { BACKDROP } from '/js/background.js?v=abd33088'; +import { + ALLOWED_HOSTS_MAX, + firstBadHost, + hostnameOf, + isLocalAddress, + parseHostList, +} from '/js/host-names.js?v=98bf44d7'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ let _passwordSet = false; @@ -165,7 +173,7 @@ export function loadSettings(c) { /* The key itself is never included in /api/config. */ const apiEl = inp('bg-apikey-inp') || inp('bg-apikey'); if (apiEl) { - apiEl.placeholder = '●●●●●●●●●● (configured)'; + apiEl.placeholder = `●●●●●●●●●● (${t('common.configured')})`; apiGet('/api/settings/unsplash-key') .then(d => { const vEl = el('ie-apikey-v'); @@ -194,13 +202,14 @@ export function loadSettings(c) { target on a phone. */ slider.style.backgroundImage = `linear-gradient(var(--slider-dir), var(--ac) 0%, var(--ac) ${pct}%, var(--bd-inner) ${pct}%, var(--bd-inner) 100%)`; } + const twoPlaces = v => formatNumber(parseFloat(v), { minimumFractionDigits: 2, maximumFractionDigits: 2 }); if (brEl) { brEl.value = bg.brightness ?? 0.62; - if (brVal) brVal.textContent = parseFloat(brEl.value).toFixed(2); + if (brVal) brVal.textContent = twoPlaces(brEl.value); updateSliderFill(brEl); brEl.addEventListener('input', () => { updateSliderFill(brEl); - if (brVal) brVal.textContent = parseFloat(brEl.value).toFixed(2); + if (brVal) brVal.textContent = twoPlaces(brEl.value); }); } el('bg-save').addEventListener('click', saveWallpaper); @@ -227,13 +236,13 @@ export function loadSettings(c) { _si('srv-ip', s.server?.hostIp || ''); _si('srv-socket', s.server?.socketProxyUrl || ''); _si('srv-hosts', (s.server?.allowedHosts || []).join(', ')); - _sv('ie-bgcol-v', s.background?.collection, 'Collection ID'); + _sv('ie-bgcol-v', s.background?.collection, t('appearance.collectionId')); _si('bg-col-inp', s.background?.collection || ''); _si('bg-url-inp', s.background?.url || ''); _si('bg-fit', s.background?.fit === 'fit' ? 'fit' : 'fill'); showWallpaperFile(s.background?.url || ''); renderBgColor(s.background?.color || BACKDROP); - _sv('ie-bgurl-v', s.background?.url, 'Image URL'); + _sv('ie-bgurl-v', s.background?.url, t('appearance.imageUrl')); const ipEl = inp('srv-ip'); if (ipEl) ipEl.value = s.server?.hostIp || ''; @@ -276,7 +285,7 @@ export function loadSettings(c) { await apiPost('/api/auth/revoke-sessions', {}); toast(t('toast.sessionsRevoked'), 'ok'); } catch (e) { - toast(e.message || t('toast.saveFailed'), 'err'); + toast(errorText(e), 'err'); } finally { secRevoke.disabled = false; } @@ -358,7 +367,7 @@ function saveSwitchChange(e, apply) { } catch (err) { /* Assigning `checked` fires no event, so this does not loop. */ if (toggled.checked === value) toggled.checked = !value; - toast(t('toast.saveFailed', { err: err.message }), 'err'); + toast(t('toast.saveFailed', { err: errorText(err) }), 'err'); } }); } @@ -405,7 +414,7 @@ async function saveWallpaper() { _bgTrack?.reset(); toast(t('toast.saved')); } catch (e) { - toast(t('toast.saveFailed', { err: e.message }), 'err'); + toast(t('toast.saveFailed', { err: errorText(e) }), 'err'); } } const PASSWORD_ERROR_KEYS = Object.freeze({ @@ -439,6 +448,10 @@ async function saveServer() { return; } const allowedHosts = parseHostList(hostsText); + if (!allowedHosts) { + toast(t('toast.allowedHostsTooMany', { max: formatNumber(ALLOWED_HOSTS_MAX) }), 'err'); + return; + } const here = hostnameOf(location.host); if (!enabled && here && !isLocalAddress(here) && !allowedHosts.includes(here)) { toast(t('toast.allowedHostsKeepCurrent', { host: here }), 'err'); @@ -457,20 +470,22 @@ async function saveServer() { try { probe = await apiPost('/api/docker/test', { url }); } catch (e) { - toast(t('toast.saveFailed', { err: e.message }), 'err'); + toast(t('toast.saveFailed', { err: errorText(e) }), 'err'); return; } /* The error and the hint are both needed: "connection refused" for an IP is exactly what a proxy published on the host's loopback looks like from inside a container. */ const hint = SOCKET_HINTS[probe.hint] ? ` ${t(SOCKET_HINTS[probe.hint])}` : ''; + const advice = socketProbeAdvice(probe); + const socketReason = t(advice.key, advice.vars); if (!probe.ok && probe.fatal) { - toast(t('toast.socketUrlBad', { reason: probe.error }) + hint, 'err'); + toast(t('toast.socketUrlBad', { reason: socketReason }) + hint, 'err'); return; } /* Not answering yet is not the same as wrong. A proxy still starting would otherwise block a save that is correct. */ - if (!probe.ok) socketWarning = t('toast.socketUrlUnverified', { reason: probe.error }) + hint; + if (!probe.ok) socketWarning = t('toast.socketUrlUnverified', { reason: socketReason }) + hint; } /* Switching protection off deletes the stored password. Ask before anything @@ -504,7 +519,7 @@ async function saveServer() { const pwEl = inp('sec-pw'); if (pwEl) { pwEl.value = ''; - pwEl.placeholder = '●●●●●●●●●● (configured)'; + pwEl.placeholder = `●●●●●●●●●● (${t('common.configured')})`; } } await apiPost('/api/auth/toggle', { enabled, currentPassword, allowedHosts }); @@ -545,7 +560,7 @@ async function saveServer() { } catch (e) { const key = PASSWORD_ERROR_KEYS[/** @type {any} */ (e).code]; if (key) toast(t(key), 'err'); - else toast(t('toast.saveFailed', { err: e.message }), 'err'); + else toast(t('toast.saveFailed', { err: errorText(e) }), 'err'); await syncAuthFromServer(); } } diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index 258a517..8a18743 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,10 +1,12 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=fc7f0836'; -import { el, q } from '/js/utils.js?v=9a9bfb54'; -import { t } from '/js/i18n.js?v=1f1ea9c1'; -import { iconChain } from '/js/icons.js?v=9c8c550c'; +import { el, isolate, q } from '/js/utils.js?v=fdc0243f'; +import { t } from '/js/i18n.js?v=5579776a'; +import { errorAdvice } from '/js/admin-error.js?v=61f73e4d'; +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 { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=706fc9a7'; +import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=dbe542e1'; export const API = ''; @@ -58,17 +60,31 @@ function tagged(status, body) { return e; } -/** A body that is not JSON is the web server answering on its own. +/** An error whose message is already a sentence in the reader's language. */ +export class ShownError extends Error {} - @param {Response} r @returns {Promise} */ +/** The sentence for a failed action. Never the API's own text: it is English + written for a log. + @param {unknown} e @returns {string} */ +export function errorText(e) { + if (e instanceof ShownError) return e.message; + const { key, vars } = errorAdvice(e); + if (vars && typeof vars.max === 'number') vars.max = formatNumber(vars.max); + return t(key, vars); +} + +/** For a raw `fetch`. A body that is not JSON is the web server answering on + its own. + + @param {Response} r @returns {Promise} */ export async function responseError(r) { const text = await r.text().catch(() => ''); + let body = null; try { - const d = JSON.parse(text); - if (d && d.error) return String(d.error); + body = JSON.parse(text); } catch {} - if (r.status === 413) return t('toast.imageTooLarge'); - return `HTTP ${r.status}`; + if (!body && r.status === 413) return new ShownError(t('toast.imageTooLarge')); + return tagged(r.status, body); } /* Set by the admin entry point, never imported. The sign-in screen imports this @@ -119,7 +135,10 @@ 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 || []).map(w => w.label).filter(Boolean); + const withheld = (body?.withheld || []) + .map(w => w.label) + .filter(Boolean) + .map(isolate); if (withheld.length) toast(t('toast.secretsWithheld', { items: withheld.join(', ') }), 'err'); return body; }; diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index ab46cd7..192f2a7 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=f5551857'; -import { createListbox } from '/js/listbox.js?v=30c1b9d1'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=c458cf42'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=81ab2f92'; +import { createListbox } from '/js/listbox.js?v=11c869c4'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=0e9c05db'; 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=1f1ea9c1'; -import { focusFirst, q, qi, qa } from '/js/utils.js?v=9a9bfb54'; +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'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); @@ -50,9 +50,10 @@ function _renderWidgetForm(body) { const addNew = body.querySelector(':scope > .add-new-card'); body.replaceChildren(...(addNew ? [addNew] : [])); - const typeList = [...Object.values(state._widgetReg).map(w => [w.name, w.label]), ['custom', 'Custom']].sort((a, b) => - a[1].localeCompare(b[1]), - ); + const typeList = [ + ...Object.values(state._widgetReg).map(w => [w.name, w.label]), + ['custom', t('widgetCfg.typeCustom')], + ].sort((a, b) => a[1].localeCompare(b[1])); const shell = document.createElement('div'); shell.className = 'grp'; setHtml( @@ -214,14 +215,14 @@ function _renderCustomConfig(body) { 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url', - ].map(v => ({ value: v, label: v || 'Default' })); + ].map(v => ({ value: v, label: v || t('widgetCfg.referrerDefault') })); setHtml( adv, html`
${t('widgetCfg.referrerPolicy')}
${t('widgetCfg.allowFeaturePolicy')}${o.allow ? o.allow : 'autoplay; fullscreen'}
${t('widgetCfg.allowFullscreen')}
-
${t('widgetCfg.refreshInterval')} (ms)${o.refreshInterval ? o.refreshInterval : 'e.g. 2000'}
`, +
${t('widgetCfg.refreshInterval')} (ms)${o.refreshInterval ? o.refreshInterval : t('widgetCfg.refreshPh')}
`, ); const refBox = createListbox({ label: t('widgetCfg.referrerPolicy'), @@ -246,7 +247,7 @@ function _renderCustomConfig(body) { }, }); initInlineEdit('if-refresh-row', 'if-refresh', { - placeholder: 'e.g. 2000', + placeholder: () => t('widgetCfg.refreshPh'), onCommit() { sync(); }, diff --git a/public/js/admin.js b/public/js/admin.js index 3440c32..dcdc0d3 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,24 +1,25 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=5703fb88'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=276069af'; -import { recoveryShown } from '/js/config-recovery.js?v=706fc9a7'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=fc1559fb'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=a707f78e'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=df3c72ef'; +import { recoveryShown } from '/js/config-recovery.js?v=dbe542e1'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=6d020ac7'; import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; import { + afterImport, buildAppItem, claimFolderChildren, newItemId, - saveWithRevert, + revertingSaves, serialWrites, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=8389782f'; +} from '/js/admin-save-logic.js?v=ae65f9c8'; import { loadSettings, savedWallpaperUrl, settingsDirty, showBgFields, showWallpaperFile, -} from '/js/admin-settings.js?v=88b89f37'; +} from '/js/admin-settings.js?v=7fbd9499'; import { apiGet, apiPost, @@ -26,18 +27,21 @@ import { nameEditPen, paintIcon, reveal, + errorText, responseError, + ShownError, setReauthHandler, toast, -} from '/js/admin-shared.js?v=f5551857'; +} from '/js/admin-shared.js?v=81ab2f92'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=53ceafd3'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=b05766fe'; 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=30c1b9d1'; +import { createListbox } from '/js/listbox.js?v=11c869c4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=1f1ea9c1'; -import { loadLocalIcons } from '/js/icons.js?v=9c8c550c'; +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 { clearSkipTls, @@ -48,7 +52,7 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=dda5296a'; +} from '/js/import-foreign.js?v=9efb127f'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { @@ -60,8 +64,21 @@ import { watchSystemTheme, writeMode, } from '/js/theme.js?v=eeafa4b5'; -import { el, focusFirst, inp, q, qa, clr, setUserText, storeGet, storeSet, tgt } from '/js/utils.js?v=9a9bfb54'; -import { applyBackground, resolveBackground } from '/js/background.js?v=43a04bdb'; +import { + el, + focusFirst, + initial, + inp, + isolate, + q, + qa, + clr, + setUserText, + 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'; ensureSprite(); @@ -125,25 +142,37 @@ async function applyBg() { let _serverItems = ''; const saves = serialWrites(); +/* Counts imports that replaced a stale list. Refuse a save asked for before + one: its change was made on the replaced list. */ +let _replaced = 0; + /** Returns whether the write reached the server. */ function save() { - return saves.run(writeItems); + const asked = _replaced; + return saves.run(() => writeItems(asked)); } -async function writeItems() { +/** @param {number} asked */ +async function writeItems(asked) { let ok = false; try { const full = await apiGet('/api/config'); - if (JSON.stringify(full.items || []) !== _serverItems) throw Object.assign(new Error('stale'), { status: 409 }); + if (asked !== _replaced || JSON.stringify(full.items || []) !== _serverItems) { + throw Object.assign(new Error('stale'), { status: 409 }); + } const sent = JSON.stringify(state.items); - full.items = state.items; + full.items = JSON.parse(sent); const r = await apiPost('/api/config', full); _serverItems = JSON.stringify(r.items); _savedItems = sent; + saveOrRestore.landed(JSON.parse(sent)); toast(t('toast.saved')); ok = true; } catch (e) { - toast(e.status === 409 ? t('toast.dashboardChangedElsewhere') : t('toast.saveFailed', { err: e.message }), 'err'); + toast( + e.status === 409 ? t('toast.dashboardChangedElsewhere') : t('toast.saveFailed', { err: errorText(e) }), + 'err', + ); } render(); syncDashSave(); @@ -167,30 +196,39 @@ async function appendItems(newItems) { /* Ids were allocated against the list the preview was built from. */ const taken = new Set(current.map(i => i && i.id)); const clash = newItems.find(i => taken.has(i.id)); - if (clash) throw new Error(`${clash.label}: this id already exists. Reload and import again.`); + if (clash) throw new ShownError(t('toast.importIdTaken', { name: isolate(clash.label || clash.id) })); full.items = [...current, ...newItems]; const r = await apiPost('/api/config', full); + const next = afterImport({ + local: state.items, + saved: JSON.parse(_savedItems), + current, + serverItems: _serverItems, + newItems, + }); + if (next.stale) _replaced++; + state.items = next.items; _serverItems = JSON.stringify(r.items); - state.items = full.items; - _savedItems = JSON.stringify(state.items); + _savedItems = JSON.stringify(next.saved); + saveOrRestore.landed(JSON.parse(_savedItems)); syncDashSave(); } finally { render(); } } +const saveOrRestore = revertingSaves({ + write: save, + restore: items => { + state.items = snapshotItems(items); + render(); + }, +}); + /** Save, and put the list back if the write did not land. Never rejects. */ async function saveOrRevert(before) { try { - return await saveWithRevert({ - write: save, - snapshot: before, - restore: items => { - state.items = items; - render(); - }, - superseded: () => saves.pending() > 0, - }); + return await saveOrRestore(before); } catch { return false; } @@ -300,7 +338,7 @@ function openModal(idx) { const isEdit = idx != null; const evTitle = el('ev-title'); - if (isEdit) setUserText(evTitle, t('common.editNamed', { name: item.label || item.id })); + if (isEdit) setUserText(evTitle, t('common.editNamed', { name: isolate(item.label || item.id) })); else { evTitle.textContent = t('type.addNew'); evTitle.removeAttribute('dir'); @@ -331,8 +369,8 @@ async function _evDelete(item, idx) { const ok = await confirmText({ title: t('common.delete'), text: isFolder - ? t('confirm.deleteFolder', { name: item.label }) - : t('confirm.remove', { name: item.label || item.id }), + ? t('confirm.deleteFolder', { name: isolate(item.label || item.id) }) + : t('confirm.remove', { name: isolate(item.label || item.id) }), confirmLabel: t('common.delete'), cancelLabel: t('common.cancel'), destructive: true, @@ -379,7 +417,7 @@ function openFolderPicker(appId, targetFolderId = null) { const appName = appItem?.label || appId; const dlg = openDialog({ - title: appId ? t('folder.moveTo', { name: appName }) : t('folder.addApp'), + title: appId ? t('folder.moveTo', { name: isolate(appName) }) : t('folder.addApp'), }); const list = dlg.body; const close = dlg.close; @@ -417,7 +455,7 @@ function openFolderPicker(appId, targetFolderId = null) { const ri = document.createElement('span'); ri.className = 'fp-ic'; ri.style.background = clr(app.color); - paintIcon(ri, app.iconUrl, (app.label || '?')[0]); + paintIcon(ri, app.iconUrl, initial(app.label)); const nm = document.createElement('span'); nm.className = 'fp-nm'; setUserText(nm, app.label || app.id); @@ -624,7 +662,7 @@ async function doSave(orig) { closeModal(item.id); toast(t(replaced ? 'toast.updated' : 'toast.added')); } catch (e) { - toast(t('toast.error', { err: e.message }), 'err'); + toast(t('toast.error', { err: errorText(e) }), 'err'); } finally { _editorSaving = false; } @@ -692,7 +730,7 @@ function initAllInlineEdits() { const apiInp = document.createElement('input'); apiInp.id = 'bg-apikey-inp'; document.body.appendChild(apiInp); - initInlineEdit('ie-apikey', 'bg-apikey-inp', { placeholder: 'Paste your Unsplash API key' }); + initInlineEdit('ie-apikey', 'bg-apikey-inp', { placeholder: () => t('appearance.unsplashKeyPh') }); const colInp = document.createElement('input'); colInp.id = 'bg-col-inp'; @@ -854,12 +892,12 @@ function initWallpaperUpload() { const form = new FormData(); form.append('wallpaper', file, file.name); const r = await fetch('/api/wallpaper/upload', { method: 'POST', body: form }); - if (!r.ok) throw new Error(await responseError(r)); + if (!r.ok) throw await responseError(r); const d = await r.json(); setWallpaperUrl(d.url); toast(t('toast.wallpaperStored')); } catch (e) { - toast(t('toast.wallpaperFailed', { err: e.message }), 'err'); + toast(t('toast.wallpaperFailed', { err: errorText(e) }), 'err'); } finally { btn.textContent = orig; /** @type {HTMLInputElement} */ (input).value = ''; @@ -880,14 +918,14 @@ async function fetchWallpaperLink(url) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }); - if (!r.ok) throw new Error(await responseError(r)); + if (!r.ok) throw await responseError(r); const d = await r.json(); setWallpaperUrl(d.url); toast(t('toast.wallpaperStored')); } catch (e) { /* A link that failed must not replace the wallpaper already saved. */ setWallpaperUrl(savedWallpaperUrl()); - toast(t('toast.wallpaperFailed', { err: e.message }), 'err'); + toast(t('toast.wallpaperFailed', { err: errorText(e) }), 'err'); } } @@ -1026,7 +1064,7 @@ el('btn-exp').onclick = async () => { a.click(); document.body.removeChild(a); } catch (e) { - toast(t('toast.exportFailed', { err: e.message }), 'err'); + toast(t('toast.exportFailed', { err: errorText(e) }), 'err'); } finally { /* Revoked on the next frame. Revoking it in this one races the download the click just started. */ @@ -1038,7 +1076,7 @@ el('imp').onchange = async e => { if (!f) return; try { const d = JSON.parse(await f.text()); - if (!d || !Array.isArray(d.items)) throw new Error('Invalid'); + 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, @@ -1058,7 +1096,12 @@ el('imp').onchange = async e => { } const lead = document.createElement('p'); lead.className = 'dlg-lead'; - lead.textContent = t('import.confirm', { count: d.items.length, added, updated, deleted }); + lead.textContent = t('import.confirm', { + count: d.items.length, + added: formatNumber(added), + updated: formatNumber(updated), + deleted: formatNumber(deleted), + }); const ok = await confirmModal({ title: t('import.confirmTitle'), body: lead, @@ -1074,7 +1117,8 @@ el('imp').onchange = async e => { state.items = d.items; if (await saveOrRevert(before)) toast(t('toast.imported')); } catch (err) { - toast(t('toast.importFailed', { err: err.message }), 'err'); + const why = err instanceof SyntaxError ? t('toast.importNotStackyard') : errorText(err); + toast(t('toast.importFailed', { err: why }), 'err'); } tgt(e).value = ''; }; @@ -1110,7 +1154,7 @@ function dlgSection(parent, heading, rows) { if (!rows.length) return; const h = document.createElement('div'); h.className = 'dlg-sec'; - h.textContent = `${heading} (${rows.length})`; + h.textContent = `${heading} (${formatNumber(rows.length)})`; const ul = document.createElement('ul'); ul.className = 'dlg-ul'; for (const row of rows) { @@ -1146,16 +1190,17 @@ el('imp-foreign').onchange = async e => { ({ doc, errors: parseErrors } = parseYamlTolerant(await file.text())); } catch (err) { if (err instanceof YamlLiteError) - throw new Error(t('toast.importYamlUnsupported', { file: file.name, reason: err.reason, line: err.line })); + throw new ShownError(t('toast.importYamlUnsupported', { file: isolate(file.name), line: err.line })); throw err; } - const kind = detectSource(doc); - if (!kind) throw new Error(t('toast.importUnknownFormat', { file: file.name })); let out; try { + const kind = detectSource(doc); + if (!kind) throw new ShownError(t('toast.importUnknownFormat', { file: isolate(file.name) })); out = convert(kind, doc, taken, t('importForeign.untitledFolder')); } catch (err) { - if (err instanceof ImportTooLargeError) throw new Error(t('toast.importTooLarge', { file: file.name })); + if (err instanceof ImportTooLargeError) + throw new ShownError(t('toast.importTooLarge', { file: isolate(file.name) })); throw err; } items.push(...out.items); @@ -1195,7 +1240,11 @@ el('imp-foreign').onchange = async e => { t('importForeign.willCreate'), items .filter(i => i.type === 'folder') - .map(i => ({ name: i.label, group: '', why: t('importForeign.appCount', { n: i.children.length }) })), + .map(i => ({ + name: i.label, + group: '', + why: t('importForeign.appCount', { n: formatNumber(i.children.length) }), + })), ); dlgSection( body, @@ -1216,7 +1265,7 @@ el('imp-foreign').onchange = async e => { if (insecure.length) { const heading = document.createElement('div'); heading.className = 'dlg-sec'; - heading.textContent = `${t('app.allowSelfSigned')} (${insecure.length})`; + heading.textContent = `${t('app.allowSelfSigned')} (${formatNumber(insecure.length)})`; const choice = document.createElement('label'); choice.className = 'dlg-choice'; skipTlsChoice = document.createElement('input'); @@ -1253,9 +1302,9 @@ el('imp-foreign').onchange = async e => { /* Appended, never merged. An import must not rename, reorder or remove anything already on the dashboard. */ await appendAndSave(items); - toast(t('toast.importForeignDone', { apps, folders })); + toast(t('toast.importForeignDone', { apps: formatNumber(apps), folders: formatNumber(folders) })); } catch (err) { - toast(t('toast.importFailed', { err: err.message }), 'err'); + toast(t('toast.importFailed', { err: errorText(err) }), 'err'); } input.value = ''; }; @@ -1278,7 +1327,7 @@ setReauthHandler(requireLogin); const loadOrShowFailure = () => load().catch(e => { - toast(t('toast.configLoadFailed', { err: e.message }), 'err'); + toast(t('toast.configLoadFailed', { err: errorText(e) }), 'err'); const al = el('al'); if (al) { /* An inline onclick is blocked by the CSP. */ diff --git a/public/js/background.js b/public/js/background.js index dfa46b7..2ca5be2 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=9a9bfb54'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=fdc0243f'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/public/js/badge-popover.js b/public/js/badge-popover.js index 3ecc8ad..aafe563 100644 --- a/public/js/badge-popover.js +++ b/public/js/badge-popover.js @@ -2,6 +2,8 @@ /* One element for the whole dashboard. A per-tile popover outlives the tiles the grid rebuilds on every resize. */ +import { formatNumber } from '/js/format-number.js?v=349a741d'; + const HOVER_IN_MS = 320; const COMPAT_CLICK_MS = 500; /* SC 1.4.13 asks that hover content be hoverable. The pointer has to cross the @@ -80,7 +82,8 @@ function open(badge) { name.textContent = row.name; const val = document.createElement('span'); val.className = 'badge-pop-val'; - val.textContent = row.unit ? `${row.value} ${row.unit}` : String(row.value); + const value = typeof row.value === 'number' ? formatNumber(row.value) : row.value; + val.textContent = row.unit ? `${value} ${row.unit}` : value; line.append(dot, name, val); p.appendChild(line); } diff --git a/public/js/config-recovery.js b/public/js/config-recovery.js index a673c8c..0393708 100644 --- a/public/js/config-recovery.js +++ b/public/js/config-recovery.js @@ -1,6 +1,6 @@ // @ts-check import { esc, html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { LANGUAGES, initI18n, t } from '/js/i18n.js?v=1f1ea9c1'; +import { LANGUAGES, initI18n, t } from '/js/i18n.js?v=5579776a'; import { sanitizeI18nMarkup } from '/js/i18n-markup.js?v=8c90e1dd'; import { fillNames, pickLanguage, screenFor } from '/js/config-recovery-logic.js?v=e6e24763'; diff --git a/public/js/dashboard-switch-model.js b/public/js/dashboard-switch-model.js index 6de5365..030379d 100644 --- a/public/js/dashboard-switch-model.js +++ b/public/js/dashboard-switch-model.js @@ -3,14 +3,14 @@ keychain. */ export const PALETTE = [ - { id: 'teal', label: 'Teal', hex: '#14b8c6' }, - { id: 'cyan', label: 'Cyan', hex: '#22c1d6' }, - { id: 'orange', label: 'Orange', hex: '#ef8a2b' }, - { id: 'pink', label: 'Pink', hex: '#f5325b' }, - { id: 'purple', label: 'Purple', hex: '#7c5cff' }, - { id: 'green', label: 'Green', hex: '#2fbf71' }, - { id: 'yellow', label: 'Yellow', hex: '#f4c430' }, - { id: 'blue', label: 'Blue', hex: '#2f6df4' }, + { id: 'teal', hex: '#14b8c6' }, + { id: 'cyan', hex: '#22c1d6' }, + { id: 'orange', hex: '#ef8a2b' }, + { id: 'pink', hex: '#f5325b' }, + { id: 'purple', hex: '#7c5cff' }, + { id: 'green', hex: '#2fbf71' }, + { id: 'yellow', hex: '#f4c430' }, + { id: 'blue', hex: '#2f6df4' }, ]; /* Each id maps to an art asset in the widget frontend. */ diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 4e572a7..a0a4dca 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -1,4 +1,4 @@ -import { loadLocalIcons, iconChain } from '/js/icons.js?v=9c8c550c'; +import { loadLocalIcons, iconChain } from '/js/icons.js?v=9c7b5111'; import { WIDGET_HEIGHTS, WIDGET_DESIGN, @@ -12,6 +12,7 @@ import { import { el, ICON_R, + inertAllBut, isDashboardEmpty, mk, mkWrap as _mkWrap, @@ -26,13 +27,14 @@ import { storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=9a9bfb54'; +} from '/js/utils.js?v=fdc0243f'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=17a182a1'; +import { initSpotlight } from '/js/spotlight.js?v=6b79fa8a'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; -import { initI18n, t, currentLang } from '/js/i18n.js?v=1f1ea9c1'; -import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=706fc9a7'; +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=61f73e4d'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { @@ -43,10 +45,10 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=c64101c8'; +} from '/js/ui.js?v=1d2ce8a6'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=9e6d9d4b'; -import { formatNumber } from '/js/format-number.js?v=4a5ccef4'; -import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3'; +import { formatNumber } from '/js/format-number.js?v=349a741d'; +import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=1e0ab5d1'; import { observeGlass } from '/js/glass-rim.js?v=3faec233'; import { configChanged, @@ -55,11 +57,11 @@ import { landingAfterSetup, restorePage, } from '/js/dashboard-logic.js?v=0d519f8b'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=43a04bdb'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=abd33088'; 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=c1ac6fb8'; +import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; import { ensureSprite, iconSvg } from '/js/icon-set.js?v=34af798f'; import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43'; import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5'; @@ -371,7 +373,7 @@ function mkDot(i, total, current, go) { const d = mk('button'); d.type = 'button'; d.className = 'dot' + (i === current ? ' on' : ''); - d.setAttribute('aria-label', t('home.goToPage', { page: i + 1, total })); + d.setAttribute('aria-label', t('home.goToPage', { page: formatNumber(i + 1), total: formatNumber(total) })); if (i === current) d.setAttribute('aria-current', 'true'); d.onclick = () => go(i); return d; @@ -448,7 +450,7 @@ function applyPollRates() { function announcePage(index, total) { const live = el('page-live'); if (!live) return; - live.textContent = t('home.pageAnnounce', { page: index + 1, total }); + live.textContent = t('home.pageAnnounce', { page: formatNumber(index + 1), total: formatNumber(total) }); } /* A page that has scrolled off is still in the DOM and still focusable, so Tab @@ -458,11 +460,7 @@ function announcePage(index, total) { /** @param {number} current */ function syncPageInert(current) { const strip = el('pages'); - if (!strip) return; - [...strip.children].forEach((page, i) => { - if (i === current) page.removeAttribute('inert'); - else page.setAttribute('inert', ''); - }); + if (strip) inertAllBut(strip, current); } function goTo(n, dotEls, announce = true) { @@ -713,12 +711,15 @@ function showSetupPrompt() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw.value }), }); - if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || t('setup.failed')); + if (!r.ok) { + const body = await r.json().catch(() => null); + throw Object.assign(new Error(`HTTP ${r.status}`), { kind: body?.kind, code: body?.code }); + } const to = landingAfterSetup(items); if (to) location.href = to; else location.reload(); } catch (e) { - err.textContent = e.message; + err.textContent = t(setupErrorKey(e)); err.style.display = 'block'; setB.disabled = false; skip.disabled = false; @@ -733,6 +734,24 @@ function showSetupPrompt() { } async function boot() { + const settled = p => + p.then( + v => ({ v }), + e => ({ e }), + ); + const loadConfig = () => + settled(fetch('/api/config', { cache: 'no-store', signal: AbortSignal.timeout(BOOT_TIMEOUT_MS) })); + let configReq = loadConfig(); + 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 }); + return r.json(); + }), + ); + let widgetsReq = loadWidgets(); + let iconsReq = loadLocalIcons(); + let authData = null; try { const authCheck = await fetch('/api/auth/check', { @@ -753,13 +772,20 @@ 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 */ } let configFailed = false; try { - const res = await fetch('/api/config', { cache: 'no-store', signal: AbortSignal.timeout(BOOT_TIMEOUT_MS) }); + const got = await configReq; + if (got.e) throw got.e; + const res = got.v; if (!res.ok) throw new Error(`HTTP ${res.status}`); const c = await res.json(); /* Saving rejects these, but a config written earlier still reaches here. @@ -773,7 +799,7 @@ async function boot() { configFailed = true; } - await loadLocalIcons(); + await iconsReq; if (configFailed) { /* The catalog is loaded as the last step of the fetch that just failed, so @@ -795,15 +821,13 @@ async function boot() { if (authData && !authData.setupPrompted && !authData.passwordSet) { await showSetupPrompt(); + /* A password set in the prompt refuses a request that was still open. */ + if ((await widgetsReq).e) widgetsReq = loadWidgets(); } - try { - const wr = await (await fetch('/api/widgets', { cache: 'no-store' })).json(); - widgetReg = Object.create(null); - for (const w of wr.widgets || []) if (w && w.name) widgetReg[w.name] = w; - } catch { - widgetReg = Object.create(null); - } + widgetReg = Object.create(null); + const wr = (await widgetsReq).v; + for (const w of Array.isArray(wr?.widgets) ? wr.widgets : []) if (w && w.name) widgetReg[w.name] = w; const state = { items, diff --git a/public/js/format-number.js b/public/js/format-number.js index ffff7b9..5396af0 100644 --- a/public/js/format-number.js +++ b/public/js/format-number.js @@ -1,10 +1,17 @@ // @ts-check -/* The language picks the words, the locale picks the digits. A number that +/* The interface language picks the digits, the reader's locale picks the + separators. A locale that names its own numbering system wins. A number that identifies rather than counts is not passed through here. */ /** @type {Intl.NumberFormat|null} */ let cached = null; let cachedFor = ''; +let language = ''; + +/** @param {string} code */ +export function setNumberLanguage(code) { + language = String(code || ''); +} /** The reader's locale, or 'en' where the runtime will not say. */ function locale() { @@ -15,23 +22,36 @@ function locale() { } } +/** @param {string} loc @returns {string|undefined} */ +function numberingSystem(loc) { + if (!language) return undefined; + try { + if (new Intl.Locale(loc).numberingSystem) return undefined; + return new Intl.NumberFormat(language).resolvedOptions().numberingSystem; + } catch { + return undefined; + } +} + /** @param {number} value @param {Intl.NumberFormatOptions} [options] @returns {string} */ export function formatNumber(value, options) { if (typeof value !== 'number' || !Number.isFinite(value)) return String(value); const loc = locale(); + const nu = numberingSystem(loc); if (options) { try { - return new Intl.NumberFormat(loc, options).format(value); + return new Intl.NumberFormat(loc, nu ? { numberingSystem: nu, ...options } : options).format(value); } catch { return String(value); } } /* One formatter for the common case: this runs per badge on every poll. */ - if (!cached || cachedFor !== loc) { + const key = `${loc}|${nu || ''}`; + if (!cached || cachedFor !== key) { try { - cached = new Intl.NumberFormat(loc); - cachedFor = loc; + cached = new Intl.NumberFormat(loc, nu ? { numberingSystem: nu } : undefined); + cachedFor = key; } catch { return String(value); } @@ -47,10 +67,10 @@ let digitMapFor = null; /** The digits of an already-formed string, for text built elsewhere. @param {string} text @returns {string} */ export function localiseDigits(text) { - const loc = locale(); - if (!digitMap || digitMapFor !== loc) { + const key = `${locale()}|${language}`; + if (!digitMap || digitMapFor !== key) { digitMap = Array.from({ length: 10 }, (_, d) => formatNumber(d)); - digitMapFor = loc; + digitMapFor = key; } return String(text).replace(/\d/g, d => digitMap[Number(d)]); } diff --git a/public/js/host-names.js b/public/js/host-names.js index c3a4686..5af09df 100644 --- a/public/js/host-names.js +++ b/public/js/host-names.js @@ -76,24 +76,24 @@ export function normalizeHostList(value) { return out.length > ALLOWED_HOSTS_MAX ? null : out; } +/* The Persian and full-width commas are what fa and zh-Hans keyboards type. */ +const SEPARATOR = /[,\u060C\uFF0C]/; + +/** @param {string} text @returns {string[]} */ +const splitHosts = text => + String(text || '') + .split(SEPARATOR) + .map(p => p.trim()) + .filter(Boolean); + /** The first entry of a comma-separated list that is not a host name, or null. @param {string} text @returns {string | null} */ export function firstBadHost(text) { - for (const part of String(text || '').split(',')) { - const p = part.trim(); - if (p && !hostnameOf(p)) return p; - } - return null; + return splitHosts(text).find(p => !hostnameOf(p)) ?? null; } -/** @param {string} text @returns {string[]} */ +/** The list as stored, or null when it holds more than ALLOWED_HOSTS_MAX names. + @param {string} text @returns {string[] | null} */ export function parseHostList(text) { - return ( - normalizeHostList( - String(text || '') - .split(',') - .map(p => p.trim()) - .filter(Boolean), - ) || [] - ); + return normalizeHostList(splitHosts(text)); } diff --git a/public/js/i18n.js b/public/js/i18n.js index 707ba9e..f99d067 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -4,6 +4,7 @@ import { setHtml } from '/js/html.js?v=c71f8903'; import { i18nMarkup } from '/js/i18n-markup.js?v=8c90e1dd'; +import { formatNumber, setNumberLanguage } from '/js/format-number.js?v=349a741d'; /* The locale registry: the one place a supported language is defined. @@ -83,7 +84,7 @@ function flatten(obj, prefix, out) { async function fetchCatalog(code) { try { - const r = await fetch(`/i18n/${code}.json`, { cache: 'no-store' }); + const r = await fetch(`/i18n/${code}.json`, { cache: 'no-cache' }); if (!r.ok) return null; return flatten(await r.json(), '', Object.create(null)); } catch { @@ -131,6 +132,7 @@ function devLocale() { export async function initI18n(code) { code = code || 'en'; const dev = devLocale(); + const wanted = !dev && code !== 'en' ? fetchCatalog(code) : null; base = (await fetchCatalog('en')) || Object.create(null); if (dev === KEY_LANG) { @@ -144,11 +146,12 @@ export async function initI18n(code) { active = mapped; current = PSEUDO_LANG; } else { - const loaded = code === 'en' ? base : await fetchCatalog(code); + const loaded = wanted ? await wanted : base; active = loaded || base; current = loaded && code !== 'en' ? code : 'en'; } + setNumberLanguage(current); const el = document.documentElement; el.setAttribute('lang', current); el.setAttribute('dir', dirFor(current)); @@ -224,7 +227,8 @@ function lookupPlural(key, count) { } /** Translate a key. Pass a numeric `count` for a counted message: it selects - the plural form and fills `{count}`. + the plural form and fills `{count}` in the reader's digits. Other + placeholders are inserted as given. @param {string} key @param {Record} [vars] @returns {string} */ export function t(key, vars) { @@ -232,6 +236,9 @@ export function t(key, vars) { let s = /** @type {string|null} */ (null); if (typeof count === 'number' && Number.isFinite(count)) s = lookupPlural(key, count); if (s == null) s = active[key] != null ? active[key] : base[key] != null ? base[key] : key; - if (vars) s = String(s).replace(/\{(\w+)\}/g, (m, k) => (vars[k] != null ? String(vars[k]) : m)); + if (vars) + s = String(s).replace(/\{(\w+)\}/g, (m, k) => + vars[k] == null ? m : k === 'count' && typeof vars[k] === 'number' ? formatNumber(vars[k]) : String(vars[k]), + ); return s; } diff --git a/public/js/icons.js b/public/js/icons.js index cadeb93..a56ed29 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -6,6 +6,7 @@ const LOCAL_ICONS = new Set(); /* The demo blocks outbound requests, so its icon proxy only ever fails. */ let _proxy = true; +/** @returns {Promise} the HTTP status, or 0 when the API did not answer */ export async function loadLocalIcons() { try { const r = await fetch('/api/icons/local', { cache: 'no-store' }); @@ -17,7 +18,10 @@ export async function loadLocalIcons() { (d.files || []).forEach(f => LOCAL_ICONS.add(f)); _proxy = d.demo !== true; } - } catch {} + return r.status; + } catch { + return 0; + } } /* Percent-encode the filename only, never the '/icons/' prefix, or the diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js index 6f11b37..1f5fabf 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=8389782f'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=ae65f9c8'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ @@ -63,15 +63,25 @@ function isAbsoluteLink(href) { /* A value Homepage or Dashy resolves from its own environment. */ const hasPlaceholder = s => /\{\{[^}]*\}\}|\$\{[^}]*\}/.test(s); +/* An alias is the same map every time. Listing a wide map's keys on each visit + freezes the tab. */ +/** @type {WeakMap} */ +const soleKeys = new WeakMap(); + /** The single key of a `{ name: value }` wrapper, or null. @param {any} v @returns {[string, any]|null} */ function soleEntry(v) { if (!isMap(v)) return null; - const keys = Object.keys(v); - return keys.length === 1 ? [keys[0], v[keys[0]]] : null; + if (!soleKeys.has(v)) { + const keys = Object.keys(v); + soleKeys.set(v, keys.length === 1 ? keys[0] : null); + } + const key = soleKeys.get(v); + return key === null ? null : [key, v[key]]; } /** Which format a parsed document is, by shape rather than by filename. + Throws ImportTooLargeError past the entry budget. @param {any} doc @returns {'homepage-services'|'homepage-bookmarks'|'dashy'|null} */ export function detectSource(doc) { if (isMap(doc)) { @@ -81,16 +91,22 @@ export function detectSource(doc) { } if (!Array.isArray(doc) || !doc.length) return null; let sawGroup = false; + let seen = 0; + const visit = () => { + if (++seen > MAX_IMPORT_ENTRIES) throw new ImportTooLargeError(); + }; for (const group of doc) { const g = soleEntry(group); if (!g || !Array.isArray(g[1])) return null; sawGroup = true; for (const entry of g[1]) { + visit(); const e = soleEntry(entry); - if (!e) continue; + if (!e || !Array.isArray(e[1])) continue; /* A bookmark's fields sit inside an extra list, a service's do not. */ - if (Array.isArray(e[1]) && e[1].some(row => isMap(row) && ('href' in row || 'abbr' in row))) { - return 'homepage-bookmarks'; + for (const row of e[1]) { + visit(); + if (isMap(row) && ('href' in row || 'abbr' in row)) return 'homepage-bookmarks'; } } } diff --git a/public/js/label-contrast.js b/public/js/label-contrast.js index d1eb614..d79c6d4 100644 --- a/public/js/label-contrast.js +++ b/public/js/label-contrast.js @@ -148,24 +148,35 @@ const GRID_ROWS = 18; @param {string} value @returns {[number, number, number]|null} */ function parseCssColor(value) { if (typeof value !== 'string' || !value.trim()) return null; - const read = (/** @type {string} */ seed) => { + if (_parsed.has(value)) return _parsed.get(value) || null; + if (!_ctx) { const cv = document.createElement('canvas'); cv.width = cv.height = 1; - const ctx = cv.getContext('2d'); - if (!ctx) return null; + _ctx = cv.getContext('2d', { willReadFrequently: true }); + if (!_ctx) return null; + } + const ctx = _ctx; + const read = (/** @type {string} */ seed) => { ctx.fillStyle = seed; ctx.fillStyle = value; + ctx.clearRect(0, 0, 1, 1); ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; return /** @type {[number, number, number]} */ ([d[0], d[1], d[2]]); }; const a = read('#000000'), b = read('#ffffff'); - if (!a || !b) return null; - if (a[0] !== b[0] || a[1] !== b[1] || a[2] !== b[2]) return null; - return a; + const rgb = a[0] === b[0] && a[1] === b[1] && a[2] === b[2] ? a : null; + if (_parsed.size >= 512) _parsed.clear(); + _parsed.set(value, rgb); + return rgb; } +/** @type {CanvasRenderingContext2D|null} */ +let _ctx = null; +/** @type {Map} */ +const _parsed = new Map(); + /** The ink for a solid fill, from the fill's own luminance. A fill the user chose cannot have an ink the project chose. Alpha on the ink diff --git a/public/js/listbox.js b/public/js/listbox.js index b08eba2..66f9e8b 100644 --- a/public/js/listbox.js +++ b/public/js/listbox.js @@ -9,7 +9,7 @@ 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=9a9bfb54'; +import { qa } from '/js/utils.js?v=fdc0243f'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/public/js/spotlight.js b/public/js/spotlight.js index accf539..dc6a419 100644 --- a/public/js/spotlight.js +++ b/public/js/spotlight.js @@ -1,6 +1,7 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=9a9bfb54'; -import { t } from '/js/i18n.js?v=1f1ea9c1'; +import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=fdc0243f'; +import { t } from '/js/i18n.js?v=5579776a'; +import { formatNumber } from '/js/format-number.js?v=349a741d'; /* Attached to the window so a re-open can undo the previous one. */ const _w = /** @type {any} */ (window); @@ -112,14 +113,14 @@ export function initSpotlight({ if (step < srcs.length) img.src = srcs[step]; else { ic.replaceChildren(); - ic.textContent = (app.label || app.id)[0].toUpperCase(); + ic.textContent = initial(app.label || app.id); ic.style.color = '#fff'; ic.style.fontWeight = '600'; } }; ic.appendChild(img); } else { - ic.textContent = (app.label || app.id)[0].toUpperCase(); + ic.textContent = initial(app.label || app.id); ic.style.color = '#fff'; ic.style.fontWeight = '600'; } @@ -145,7 +146,8 @@ export function initSpotlight({ }); res.appendChild(f); fluidHoverClear(res); - if (live) live.textContent = cur.length + ' ' + (cur.length === 1 ? t('home.result') : t('home.results')); + if (live) + live.textContent = formatNumber(cur.length) + ' ' + (cur.length === 1 ? t('home.result') : t('home.results')); inp.setAttribute('aria-expanded', 'true'); inp.setAttribute('aria-activedescendant', cur.length ? 'sr-opt-0' : ''); }; diff --git a/public/js/ui.js b/public/js/ui.js index 9c42d77..2884c7d 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,8 +1,10 @@ -import { iconChain } from '/js/icons.js?v=9c8c550c'; +import { iconChain } from '/js/icons.js?v=9c7b5111'; import { widgetSrc, cardPreset, fixedAppearance, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=9264dee5'; import { mk, clr, + inertAllBut, + initial, isDashboardEmpty, renderEmptyState, mkWrap as _mkWrap, @@ -14,9 +16,9 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=9a9bfb54'; -import { t, currentLang } from '/js/i18n.js?v=1f1ea9c1'; -import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; +} from '/js/utils.js?v=fdc0243f'; +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'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; import { mkGlassRim, observeGlass } from '/js/glass-rim.js?v=3faec233'; @@ -74,7 +76,6 @@ function mkMiniIcon(child, pointerEvents) { const plate = clr(child.color); bg.style.background = plate; if (pointerEvents === 'none') bg.style.pointerEvents = 'none'; - const onLight = toneForColor(plate) === 'dark'; if (child.iconUrl) { const srcs = iconChain(child.iconUrl); if (srcs.length) { @@ -91,16 +92,16 @@ function mkMiniIcon(child, pointerEvents) { bg.appendChild(img); } else { const s = mk('span'); - s.className = onLight ? 'folder-mini-fb fb-on-light' : 'folder-mini-fb'; + s.className = toneForColor(plate) === 'dark' ? 'folder-mini-fb fb-on-light' : 'folder-mini-fb'; if (pointerEvents === 'none') s.style.pointerEvents = 'none'; - s.textContent = (child.label || '?')[0].toUpperCase(); + s.textContent = initial(child.label); bg.appendChild(s); } } else { const s = mk('span'); - s.className = onLight ? 'folder-mini-fb fb-on-light' : 'folder-mini-fb'; + s.className = toneForColor(plate) === 'dark' ? 'folder-mini-fb fb-on-light' : 'folder-mini-fb'; if (pointerEvents === 'none') s.style.pointerEvents = 'none'; - s.textContent = (child.label || '?')[0].toUpperCase(); + s.textContent = initial(child.label); bg.appendChild(s); } return bg; @@ -400,7 +401,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { titleEl.className = 'folder-title-mobile dyn-title-mob'; css(titleEl, { '--tfs': titleFs + 'px', - left: titleLeft + 'px', + 'inset-inline-start': titleLeft + 'px', width: boxW - padH + 'px', top: boxTop - titleRendH - titleGap + Math.round(8 * ptScale) + 'px', }); @@ -434,12 +435,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { curPage = Math.max(0, Math.min(pages.length - 1, n)); strip.style.transform = strip.style.webkitTransform = `translateX(${-pageDir() * curPage * pageW}px)`; dotEls.forEach((d, j) => d.classList.toggle('on', j === curPage)); - /* A page that has scrolled off stays focusable, so Tab would leave the - visible page for tiles nobody can see. */ - [...strip.children].forEach((page, j) => { - if (j === curPage) page.removeAttribute('inert'); - else page.setAttribute('inert', ''); - }); + inertAllBut(strip, curPage); } function buildPage(apps) { @@ -754,7 +750,7 @@ export function buildMobile() { iframeOpts: item.iframe, overlayHref, mobile: true, - onSwipe: dir => goTo(st().pg + dir), + onSwipe: dir => goTo(st().pg + dir * pageDir()), }); return card; } diff --git a/public/js/utils.js b/public/js/utils.js index afabaa5..589549d 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,9 +1,9 @@ -import { iconChain } from '/js/icons.js?v=9c8c550c'; -import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; +import { iconChain } from '/js/icons.js?v=9c7b5111'; +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'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; -import { t } from '/js/i18n.js?v=1f1ea9c1'; +import { t } from '/js/i18n.js?v=5579776a'; import { pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; export const mk = (tag, a = {}) => { @@ -48,6 +48,18 @@ export const clr = c => { if (named) return named; return cssColor(c, DEFAULT_TILE_COLOR); }; +const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); +/** The first visible character, upper-cased. Indexing a string splits an emoji + into half a surrogate pair, which draws as a replacement box. + @param {string|null|undefined} text @returns {string} */ +export const initial = text => { + for (const { segment } of GRAPHEMES.segment(text || '?')) return segment.toUpperCase(); + return '?'; +}; +/** A name for a translated sentence, isolated so its punctuation stays with it + in the other direction. + @param {string} text @returns {string} */ +export const isolate = text => `\u2068${text}\u2069`; /* The plate is a colour the user chose, so the ink has to be measured from it. White on the palette's own yellow reads at 1.5:1. */ export const letterTile = (l, sz, plate) => { @@ -56,7 +68,7 @@ export const letterTile = (l, sz, plate) => { const tone = toneForColor(plate) ?? (pageTheme() === 'light' ? 'dark' : 'light'); if (tone === 'dark') e.classList.add('fb-on-light'); e.style.fontSize = Math.round(sz * 0.32) + 'px'; - e.textContent = (l || '?')[0].toUpperCase(); + e.textContent = initial(l); return e; }; export { esc } from '/js/html.js?v=c71f8903'; @@ -149,6 +161,14 @@ export function focusFirst(...els) { return false; } +/** @param {Element} parent @param {number} current */ +export function inertAllBut(parent, current) { + [...parent.children].forEach((child, i) => { + if (i === current) child.removeAttribute('inert'); + else child.setAttribute('inert', ''); + }); +} + export const ICON_R = 0.26; /* breg is passed in to avoid a circular import. */ diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index 6fea324..d20ad70 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -1,11 +1,11 @@ /* Renders a widget's declared `fields` into a config form and reads the values back. Each builder returns { el, get, control, liveValue }. */ -import { t } from '/js/i18n.js?v=1f1ea9c1'; +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=f5551857'; -import { createListbox } from '/js/listbox.js?v=30c1b9d1'; -import { renderColorControl } from '/js/admin-color-control.js?v=233683ad'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=81ab2f92'; +import { createListbox } from '/js/listbox.js?v=11c869c4'; +import { renderColorControl } from '/js/admin-color-control.js?v=426a5709'; import { seedCarried, applyOptionSet, @@ -14,8 +14,9 @@ import { groupBounds, visibleFieldFlags, } from '/js/admin-logic.js?v=fc7f0836'; -import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=f82486b2'; -import { qi } from '/js/utils.js?v=9a9bfb54'; +import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=61f73e4d'; +import { qi } from '/js/utils.js?v=fdc0243f'; +import { formatNumber } from '/js/format-number.js?v=349a741d'; const PE = ''; @@ -208,9 +209,10 @@ function _picklist(field, value, ctx, size) { for (let i = 0; i < count; i++) { const row = document.createElement('div'); row.className = 'row'; - setHtml(row, html`${rowLabel} ${i + 1}`); + const name = `${rowLabel} ${formatNumber(i + 1)}`; + setHtml(row, html`${name}`); const cur = chosen[i] != null ? String(chosen[i]) : ''; - const lb = createListbox({ label: `${rowLabel} ${i + 1}`, options: shown(cur), value: cur }); + const lb = createListbox({ label: name, options: shown(cur), value: cur }); row.appendChild(lb.el); card.appendChild(row); sels.push(lb); @@ -610,7 +612,7 @@ function _group(field, rows, size, ctx) { data.forEach((rowData, idx) => { const hdr = document.createElement('p'); hdr.className = 'grp-hdr grp-hdr-row'; - setHtml(hdr, html`${field.label} ${idx + 1}`); + setHtml(hdr, html`${field.label} ${formatNumber(idx + 1)}`); const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'grp-hdr-rm'; @@ -668,7 +670,8 @@ function _group(field, rows, size, ctx) { captureCurrent(); return [field.key, data]; }; - const missing = () => rowBuilt.flatMap((built, i) => _missingIn(built).map(l => `${l} (${field.label} ${i + 1})`)); + const missing = () => + rowBuilt.flatMap((built, i) => _missingIn(built).map(l => `${l} (${field.label} ${formatNumber(i + 1)})`)); return { el: wrap, get, control: null, liveValue: () => null, isGroup: true, missing }; } diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js index 1265775..34bbd1b 100644 --- a/public/js/widget-toolbox.js +++ b/public/js/widget-toolbox.js @@ -5,6 +5,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 { setNumberLanguage } from '/js/format-number.js?v=349a741d'; export { esc, html, setHtml }; @@ -258,12 +259,13 @@ export function barFill(percent, opts = {}) { /* A widget is an iframe and does not load the i18n module. The language arrives on the iframe URL. */ const _lang = new URLSearchParams(location.search).get('lang') || 'en'; +setNumberLanguage(_lang); let _strings = null; async function _loadStrings() { if (_lang === 'en') return; try { - const r = await fetch(`/i18n/${encodeURIComponent(_lang)}.json`, { cache: 'force-cache' }); + const r = await fetch(`/i18n/${encodeURIComponent(_lang)}.json`, { cache: 'no-cache' }); if (r.ok) _strings = (await r.json())?.widget || null; } catch { /* English is a usable answer */ @@ -290,7 +292,7 @@ export async function loadStrings() { if (!_widgetName || _lang === 'en' || _own) return; try { const r = await fetch(`/widgets/${encodeURIComponent(_widgetName)}/i18n/${encodeURIComponent(_lang)}.json`, { - cache: 'force-cache', + cache: 'no-cache', }); if (r.ok) { const parsed = await r.json(); @@ -308,10 +310,10 @@ export function wt(key, fallback) { } /** @param {number} ts @returns {string} */ -/* Digit shape follows the reader's locale. Re-exported here so a widget takes it +/* Digit shape follows the interface language. Re-exported here so a widget takes it from the toolbox rather than reaching for toLocaleString, which is the same thing until someone passes it a language. */ -export { formatNumber, localiseDigits } from '/js/format-number.js?v=4a5ccef4'; +export { formatNumber, localiseDigits } from '/js/format-number.js?v=349a741d'; export function sinceLabel(ts) { if (!ts) return ''; diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html index 7664124..b8486e7 100644 --- a/public/widgets/backup/backup.html +++ b/public/widgets/backup/backup.html @@ -123,7 +123,7 @@