From 5256f5cf2b1b9012f55ac77cce9082aae900a987 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:48:51 -0400 Subject: [PATCH] Docs: drop the DNS medium size and document the badge address messages The DNS widget offers one size, so the page states Small and shows a single preview. Add the three Live Activity address messages to the message table: the two missing-API-path forms and the refused redirect. Re-sync the embedded application code. --- public/js/admin-app-form.js | 73 ++- public/js/admin-auth.js | 6 +- public/js/admin-color-control.js | 26 +- public/js/admin-drag-logic.js | 2 +- public/js/admin-drag.js | 251 ++++++++ public/js/admin-list.js | 269 +++++++++ public/js/admin-logic.js | 30 + public/js/admin-settings.js | 42 +- public/js/admin-shared.js | 55 +- public/js/admin-state.js | 6 + public/js/admin-widget-form.js | 34 +- public/js/admin.js | 566 ++---------------- public/js/badge-logic.js | 28 +- public/js/badge-popover.js | 32 +- public/js/dashboard-logic.js | 35 -- public/js/dashboard.js | 104 ++-- public/js/dialog.js | 110 ---- public/js/format-number.js | 25 +- public/js/i18n.js | 12 +- public/js/jitter.js | 4 +- public/js/label-contrast.js | 6 +- public/js/layout.js | 12 +- public/js/mobile-metrics.js | 10 +- public/js/modal.js | 70 ++- public/js/settings-icon.js | 6 + public/js/spotlight.js | 88 ++- public/js/theme.js | 15 +- public/js/ui.js | 100 ++-- public/js/utils.js | 28 +- public/js/wallpaper-cache.js | 58 ++ public/js/widget-config-form.js | 41 +- public/js/widget-glyphs.js | 9 +- public/js/widget-toolbox.js | 23 +- public/js/widget-types.js | 2 +- public/widgets/backup/backup.html | 2 +- public/widgets/backup/i18n/fa.json | 16 +- public/widgets/books/i18n/fa.json | 10 +- public/widgets/books/index.html | 2 +- public/widgets/clock/analog.html | 89 ++- .../widgets/connections/connections-map.html | 2 +- .../widgets/connections/connections-vpn.html | 2 +- public/widgets/connections/i18n/fa.json | 20 +- public/widgets/dashboard-switch/i18n/fa.json | 4 +- public/widgets/dashboard-switch/index.html | 2 +- public/widgets/disk-health/i18n/fa.json | 10 +- public/widgets/disk-health/index.html | 2 +- public/widgets/dns/i18n/fa.json | 12 +- public/widgets/dns/index.html | 3 +- public/widgets/dns/widget.json | 3 +- public/widgets/github/contributions.html | 2 +- public/widgets/github/i18n/fa.json | 2 +- public/widgets/github/pullrequests.html | 2 +- public/widgets/nowplaying/i18n/fa.json | 12 +- public/widgets/nowplaying/index.html | 2 +- public/widgets/system-summary/i18n/fa.json | 22 +- public/widgets/system-summary/index.html | 2 +- public/widgets/weather/i18n/fa.json | 2 +- public/widgets/weather/index.html | 2 +- src/content/docs/docs/troubleshooting.md | 3 + src/content/docs/docs/widgets/dns.mdx | 5 +- 60 files changed, 1285 insertions(+), 1128 deletions(-) create mode 100644 public/js/admin-drag.js create mode 100644 public/js/admin-list.js delete mode 100644 public/js/dialog.js create mode 100644 public/js/settings-icon.js create mode 100644 public/js/wallpaper-cache.js diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index 5bf22d4..64a482f 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,9 +1,15 @@ -import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=8ca7ce3c'; +import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=d949e985'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconName } from '/js/icons.js?v=69c2b9bd'; -import { state } from '/js/admin-state.js?v=c23e6346'; -import { isDockBlocked, DOCK_MAX, clearsStoredSecret } from '/js/admin-logic.js?v=d17394da'; -import { t } from '/js/i18n.js?v=83239bf4'; +import { state } from '/js/admin-state.js?v=7d68e98e'; +import { + isDockBlocked, + DOCK_MAX, + clearsStoredSecret, + isBareHostUrl, + failureIsMissingApiPath, +} from '/js/admin-logic.js?v=dcf7c37d'; +import { t } from '/js/i18n.js?v=e644a5c5'; import { toast, ag, @@ -13,9 +19,9 @@ import { initInlineEdit, setTogDisabled, wireChecklist, -} from '/js/admin-shared.js?v=1d330931'; -import { MAX_LABELS } from '/js/badge-logic.js?v=41a929ac'; -import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=bfd0955a'; +} from '/js/admin-shared.js?v=132c869f'; +import { MAX_LABELS } from '/js/badge-logic.js?v=b3c8b6c2'; +import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=3a61c02f'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; export function buildFolderForm(body, item) { @@ -42,7 +48,7 @@ export function buildFolderForm(body, item) {
${t('folder.name')} ${item?.label || t('folder.namePh')} - +
@@ -72,7 +78,11 @@ function _wireFolderApps() { const sync = () => { const sel = qa('li[aria-selected="true"]', list); label.textContent = - sel.length === 0 ? t('folder.selectApps') : sel.length === 1 ? sel[0].textContent : sel.length + ' selected'; + sel.length === 0 + ? t('folder.selectApps') + : sel.length === 1 + ? sel[0].textContent + : t('widgetCfg.selectedCount', { count: sel.length }); }; wireChecklist(dd, btn, list, li => { li.setAttribute('aria-selected', li.getAttribute('aria-selected') === 'true' ? 'false' : 'true'); @@ -118,10 +128,10 @@ export function buildAppForm(body, item) {

${t('app.icon')}

- ${state.siurl ? html`` : html`${(item?.label || '?')[0]?.toUpperCase() || '?'}`} + ${state.siurl ? html`` : html`${(item?.label || '?')[0]?.toUpperCase() || '?'}`} - +
@@ -203,6 +213,9 @@ export function buildAppForm(body, item) { initInlineEdit('ie-static-label', 'f-static-label', { placeholder: t('app.labelPh') }); initInlineEdit('ie-burl', 'f-burl', { placeholder: t('app.apiUrlPh') }); + /* The markup carries no style attribute, so the preview is painted here. */ + const pv0 = el('ipv'); + if (pv0) pv0.style.background = rc(state.scol); renderColorControl(el('icon-color-slot'), { value: state.scol || 'dark', idPrefix: 'icon-col', @@ -307,7 +320,7 @@ function syncActMode() { const host = el('act-labels'); if (!host) return; if (!ready) { - host.innerHTML = ''; + host.replaceChildren(); return; } renderActLabels(host); @@ -347,7 +360,7 @@ function _valueSelect(idx, path) { } function renderActLabels(host) { - host.innerHTML = ''; + host.replaceChildren(); state.spaths.forEach((path, i) => { const l = actLabel(path); const hdr = document.createElement('p'); @@ -492,7 +505,7 @@ function wireActLabelDrag(host) { initInlineEdit, from the row's own label. */ function _ieRow(rowId, label, inpId, val, ph, type = 'text') { const has = val != null && val !== ''; - return html`
${label}${has ? val : ph}
`; + return html`
${label}${has ? val : ph}
`; } function wireIcon() { @@ -546,21 +559,21 @@ function wireIcon() { const file = upInput.files[0]; if (!file) return; const origText = upBtn.textContent; - upBtn.textContent = '↑ Uploading…'; + upBtn.textContent = '↑ ' + t('appearance.uploading'); try { const form = new FormData(); form.append('icon', file, file.name); const r = await fetch('/api/icons/upload', { method: 'POST', body: form }); const d = await r.json(); - if (!r.ok) throw new Error(d.error || 'Upload failed'); + if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); await loadLocalIcons(); state.siurl = d.filename; const ipIn = inpById('ip-in'); if (ipIn) ipIn.value = d.filename; updPrev(); - toast(`Uploaded ${d.filename}`); + toast(t('toast.uploaded', { name: d.filename })); } catch (e) { - toast('Upload failed: ' + e.message, 'err'); + toast(t('toast.uploadFailed', { err: e.message }), 'err'); } finally { upBtn.textContent = origText; upInput.value = ''; @@ -575,7 +588,7 @@ function wireIcon() { function showIPRes(list, rawInput) { const rs = el('iprs'); if (!rs) return; - rs.innerHTML = ''; + rs.replaceChildren(); list.forEach(ic => { const r = document.createElement('button'); r.type = 'button'; @@ -689,7 +702,9 @@ async function testPing() { const skipTls = inpById('f-skip-tls')?.checked || false; try { const r = await ap('/api/ping', { url, skipTls }); - st.textContent = r.ok ? `✓ Reachable (${r.status})` : `✗ HTTP ${r.status}`; + st.textContent = r.ok + ? '✓ ' + t('app.reachable', { status: r.status }) + : '✗ ' + t('app.httpError', { status: r.status }); } catch (e) { st.textContent = '✗ ' + e.message; } @@ -732,7 +747,7 @@ function renderKvRows(host, rows, ph) { const add = document.createElement('button'); add.type = 'button'; add.className = 'kv-add'; - setHtml(add, html`+ Add`); + setHtml(add, html`${t('common.add')}`); add.onclick = () => { rows.push({ key: '', value: '', secret: false, valueSet: false }); renderKvRows(host, rows, ph); @@ -813,8 +828,11 @@ async function fetchBadge() { state.fnums = r.numbers || []; if (st) { st.style.cssText = 'margin-top:4px;color:#34c759'; - if (!state.fnums.length) st.textContent = '✓ Connected, no numeric values found'; - else st.textContent = `✓ Found ${state.fnums.length} value${state.fnums.length !== 1 ? 's' : ''}`; + if (!state.fnums.length) { + const bare = isBareHostUrl(url); + if (bare) st.style.cssText = 'margin-top:4px;color:var(--warning)'; + st.textContent = bare ? t('app.needsApiPath') : '✓ ' + t('app.connectedNoValues'); + } else st.textContent = '✓ ' + t('app.foundValues', { count: state.fnums.length }); } el('auth-row-wrap')?.classList.remove('bprow-hidden'); if (state.fnums.length && !state.spaths.length) addActLabel(); @@ -822,11 +840,14 @@ async function fetchBadge() { } catch (e) { /* Branch on the error's `kind`, never on words inside its message. */ const advice = badgeErrorAdvice(e); + const missingPath = failureIsMissingApiPath(url, advice); if (st) { - st.style.cssText = 'margin-top:4px;color:' + (advice.tone === TONE.WARN ? 'var(--warning)' : 'var(--danger)'); - st.textContent = advice.tone === TONE.WARN ? advice.message : '✗ ' + advice.message; + const tone = missingPath || advice.tone === TONE.WARN ? 'var(--warning)' : 'var(--danger)'; + st.style.cssText = 'margin-top:4px;color:' + tone; + if (missingPath) st.textContent = t('app.needsApiPathError'); + else st.textContent = advice.tone === TONE.WARN ? advice.message : '✗ ' + advice.message; } - if (advice.openAuth) { + if (advice.openAuth && !missingPath) { const authCb = inpById('auth-en'); const authSub = el('auth-sub'); if (authCb && !authCb.checked) { diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index df85525..acfe25b 100644 --- a/public/js/admin-auth.js +++ b/public/js/admin-auth.js @@ -1,7 +1,7 @@ -import { ag, ap } from '/js/admin-shared.js?v=1d330931'; -import { t } from '/js/i18n.js?v=83239bf4'; +import { ag, ap } from '/js/admin-shared.js?v=132c869f'; +import { t } from '/js/i18n.js?v=e644a5c5'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; -import { el, inp as inpById, qa } from '/js/utils.js?v=8ca7ce3c'; +import { el, inp as inpById, qa } from '/js/utils.js?v=d949e985'; export async function checkAuth(onLogin) { try { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index e5818c7..a6f5b30 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=1d330931'; -import { t } from '/js/i18n.js?v=83239bf4'; +import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=132c869f'; +import { t } from '/js/i18n.js?v=e644a5c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=8ca7ce3c'; +import { qa, q } from '/js/utils.js?v=d949e985'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; export const BADGE_DEFAULT = '#1e6ef4'; @@ -124,11 +124,10 @@ export function renderColorControl( ) { const isSem = v => v === 'dark' || v === 'light'; const init = _hexToHsv(isSem(value) ? '#0289ff' : value) || { h: 212, s: 99, v: 100 }; - const swatch = h => - html``; + const swatch = h => html``; const swatches = semantic - ? html` - + ? html` + ${['#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df'].map(swatch)}` : html` @@ -143,8 +142,14 @@ export function renderColorControl( ${slider('Hue', 'hsb-range hsb-hue', `${idPrefix}-h`, 360, init.h, _ccIco.hueLo, _ccIco.hueHi)} ${slider('Saturation', 'hsb-range', `${idPrefix}-s`, 100, init.s, _ccIco.satLo, _ccIco.satHi)} ${slider('Brightness', 'hsb-range', `${idPrefix}-v`, 100, init.v, _ccIco.brLo, _ccIco.brHi)} -
${t('appearance.colorCode')}#rrggbb or any CSS color
`, +
${t('appearance.colorCode')}#rrggbb or any CSS color
`, ); + /* The markup carries no style attribute, so the page keeps style-src without + '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'); + if (v && v[0] === '#') /** @type {HTMLElement} */ (b).style.background = v; + } const rows = /** @type {HTMLElement[]} */ ([...wrap.children]); rows.forEach(r => container.appendChild(r)); const qLocal = sel => /** @type {HTMLInputElement} */ (container.querySelector(sel)); @@ -195,9 +200,10 @@ export function renderColorControl( }); const rb = q('.cc-rainbow', container); if (rb) rb.classList.toggle('on', mode === 'color' && showTune); - tune.forEach(r => (r.style.display = showTune ? '' : 'none')); + tune.forEach(r => r.classList.toggle('d-none', !showTune)); if (!codeRv.closest('.editing')) { - codeRv.textContent = mode === 'color' ? hex : mode === 'dark' ? 'Dark' : 'Light'; + codeRv.textContent = + mode === 'color' ? hex : t(mode === 'dark' ? 'appearance.displayDark' : 'appearance.displayLight'); codeRv.classList.remove('is-ph'); } hidden.value = mode === 'color' ? (pristine ?? hex) : mode; diff --git a/public/js/admin-drag-logic.js b/public/js/admin-drag-logic.js index 77f6e70..28cdb36 100644 --- a/public/js/admin-drag-logic.js +++ b/public/js/admin-drag-logic.js @@ -7,7 +7,7 @@ export function canJoinFolder(type) { /* Share of a folder row's height at each edge that places the dragged row next to the folder instead of inside it. */ -export const FOLDER_EDGE = 0.25; +const FOLDER_EDGE = 0.25; /** Which part of a folder row the pointer is over. @param {number} y pointer position diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js new file mode 100644 index 0000000..c8edb49 --- /dev/null +++ b/public/js/admin-drag.js @@ -0,0 +1,251 @@ +// @ts-check +/* Reordering rows in the item list, by pointer and by touch. + + It came out of admin.js because it is self-contained: seven helpers used by + nothing else, and the handlers only need the row, its item, and where the row + sits. What it cannot own is saving, so the caller injects that. + + The arrangement itself is admin-drag-logic.js, which is pure and tested + directly. This module is the DOM half: what the pointer is doing, which edge + it is near, and which classes say so. */ + +import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; +import { state } from '/js/admin-state.js?v=7d68e98e'; +import { snapshotItems } from '/js/admin-save-logic.js?v=48a9e055'; +import { qa } from '/js/utils.js?v=d949e985'; + +/** @type {(before: unknown) => void} */ +let _save = () => {}; + +/** The list writes the config, not this module. Call once at start-up. + @param {(before: unknown) => void} save @returns {void} */ +export function initDrag(save) { + _save = save; +} + +/* Applies the arrangement and saves it, or leaves the list untouched. */ +function commit(opts) { + const before = snapshotItems(state.items); + if (applyDrop(state.items, opts)) _save(before); +} + +/* dataTransfer is not readable during dragover. */ +let _dragType = null; + +function clearDragClasses(target) { + const rows = target ? [target] : qa('.row'); + rows.forEach(r => { + r.classList.remove('drag-above', 'drag-below', 'drag-into', 'drag-over'); + }); +} + +/* At most one row shows a drop marker, so moving it is a two-row edit. Sweeping + every row instead runs a query and four class removals per row on every + pointer event, which is what made dragging a long list feel heavy. The sweep + is still used once a drag ends, where its cost does not repeat. */ +let _marked = null; + +function mark(row, cls) { + if (_marked && _marked !== row) clearDragClasses(_marked); + clearDragClasses(row); + row.classList.add(cls); + _marked = row; +} + +function unmark() { + if (_marked) clearDragClasses(_marked); + _marked = null; +} + +/* Drag data formats: "top:itemId" or "child:folderId:itemId". */ +function parseDragData(raw) { + if (raw.startsWith('child:')) { + const [, sfId, sItemId] = raw.split(':'); + return { srcId: sItemId, srcFolderId: sfId }; + } + if (raw.startsWith('top:')) return { srcId: raw.slice(4), srcFolderId: null }; + return null; +} + +function itemType(id) { + return state.items.find(i => i.id === id)?.type || null; +} + +function scrollParent(el) { + for (let p = el.parentElement; p; p = p.parentElement) { + const oy = getComputedStyle(p).overflowY; + if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p; + } + return document.scrollingElement || document.documentElement; +} +function scrollByPx(scroller, dy) { + if (scroller === document.scrollingElement) window.scrollBy(0, dy); + else scroller.scrollTop += dy; +} + +/* Native HTML5 drag does not fire from touch on mobile WebKit. The handle needs + touch-action:none (see admin.css) or starting on it scrolls the list. */ +function wireTouchDrag(row, handle, { indent, folderId }) { + handle.addEventListener('pointerdown', e => { + if (e.pointerType === 'mouse') return; + if (row.draggable === false) return; /* hidden while filtering */ + e.preventDefault(); + const srcId = row.dataset.itemId; + const startRect = row.getBoundingClientRect(); + const ghost = row.cloneNode(true); + ghost.className = 'row drow drag-ghost'; + ghost.style.width = startRect.width + 'px'; + ghost.style.left = startRect.left + 'px'; + ghost.style.top = startRect.top + 'px'; + document.body.appendChild(ghost); + row.classList.add('dragging'); + handle.setPointerCapture(e.pointerId); + const offY = e.clientY - startRect.top; + let hovered = null, + dropAbove = false, + dropInto = false, + scrollTimer = null; + const scroller = scrollParent(row); + + const place = (x, y) => { + ghost.style.top = y - offY + 'px'; + const under = /** @type {HTMLElement} */ (document.elementFromPoint(x, y)); + const tr = /** @type {HTMLElement} */ (under && under.closest('.drow')); + if (!tr || tr === row || tr === ghost) { + unmark(); + hovered = null; + return; + } + hovered = tr; + const r = tr.getBoundingClientRect(); + if (tr.dataset.isFolder && canJoinFolder(itemType(srcId))) { + const zone = folderRowZone(y, r); + dropInto = zone === 'into'; + dropAbove = zone === 'above'; + mark(tr, dropInto ? 'drag-into' : dropAbove ? 'drag-above' : 'drag-below'); + } else { + dropInto = false; + dropAbove = y < r.top + r.height / 2; + mark(tr, dropAbove ? 'drag-above' : 'drag-below'); + } + }; + /* Scaled by elapsed time, not by frame. A frame is 16ms at 60Hz and 8ms at + 120Hz, so a fixed step per frame scrolls twice as fast on the faster + display. */ + const SCROLL_PX_PER_MS = 12 / 16; + const autoscroll = y => { + if (scrollTimer) { + cancelAnimationFrame(scrollTimer); + scrollTimer = null; + } + const rect = + scroller === document.scrollingElement + ? { top: 0, bottom: window.innerHeight } + : scroller.getBoundingClientRect(); + const M = 52; + const up = y < rect.top + M, + dn = y > rect.bottom - M; + if (!up && !dn) return; + let last = performance.now(); + const step = now => { + scrollByPx(scroller, (up ? -1 : 1) * (now - last) * SCROLL_PX_PER_MS); + last = now; + scrollTimer = requestAnimationFrame(step); + }; + scrollTimer = requestAnimationFrame(step); + }; + const move = ev => { + place(ev.clientX, ev.clientY); + autoscroll(ev.clientY); + }; + const end = () => { + handle.removeEventListener('pointermove', move); + handle.removeEventListener('pointerup', up); + handle.removeEventListener('pointercancel', cancel); + if (scrollTimer) cancelAnimationFrame(scrollTimer); + ghost.remove(); + row.classList.remove('dragging'); + _marked = null; + clearDragClasses(); + }; + const up = () => { + const tr = hovered; + end(); + if (!tr) return; + const into = !!tr.dataset.isFolder && canJoinFolder(itemType(srcId)) && dropInto; + commit({ + srcId, + srcFolderId: indent ? folderId : null, + targetId: tr.dataset.itemId, + targetFolderId: tr.dataset.folderId || null, + targetIsFolder: into, + indent: !!tr.dataset.indent, + childIdx: tr.dataset.childIdx != null ? Number(tr.dataset.childIdx) : null, + dropAbove: into ? false : dropAbove, + }); + }; + const cancel = () => end(); + handle.addEventListener('pointermove', move); + handle.addEventListener('pointerup', up); + handle.addEventListener('pointercancel', cancel); + }); +} + +/** Every drag affordance for one row: the pointer handlers and the touch + fallback. + + @param {HTMLElement} row @param {HTMLElement} handle + @param {{ item: any, indent: boolean, folderId: string|null, childIdx: number|null }} at + @returns {void} */ +export function wireRowDrag(row, handle, { item, indent, folderId, childIdx }) { + const dragData = indent ? 'child:' + folderId + ':' + item.id : 'top:' + item.id; + + row.addEventListener('dragstart', e => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragData); + _dragType = item.type; + requestAnimationFrame(() => row.classList.add('dragging')); + }); + row.addEventListener('dragend', () => { + row.classList.remove('dragging'); + _dragType = null; + _marked = null; + clearDragClasses(); + }); + row.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = row.getBoundingClientRect(); + if (row.dataset.isFolder && canJoinFolder(_dragType)) { + const zone = folderRowZone(e.clientY, rect); + mark(row, zone === 'into' ? 'drag-into' : zone === 'above' ? 'drag-above' : 'drag-below'); + } else { + mark(row, e.clientY < rect.top + rect.height / 2 ? 'drag-above' : 'drag-below'); + } + }); + row.addEventListener('dragleave', e => { + if (!e.relatedTarget || !row.contains(/** @type {Node} */ (e.relatedTarget))) clearDragClasses(row); + }); + + row.addEventListener('drop', e => { + e.preventDefault(); + const dropAbove = row.classList.contains('drag-above'); + const dropInto = row.classList.contains('drag-into'); + clearDragClasses(); + const raw = e.dataTransfer.getData('text/plain'); + if (!raw) return; + const drop = parseDragData(raw); + if (!drop) return; + commit({ + ...drop, + targetId: item.id, + targetFolderId: folderId, + targetIsFolder: item.type === 'folder' && dropInto, + indent, + childIdx, + dropAbove, + }); + }); + + wireTouchDrag(row, handle, { indent, folderId }); +} diff --git a/public/js/admin-list.js b/public/js/admin-list.js new file mode 100644 index 0000000..d01f87f --- /dev/null +++ b/public/js/admin-list.js @@ -0,0 +1,269 @@ +// @ts-check +/* The list of apps, widgets and folders on the Settings dashboard page: one + row, the whole list, and the filter chips above it. + + It came out of admin.js because that file is the largest in the project and + this is the largest thing in it. Unlike the drag module it does not stand + alone: opening the editor, opening the folder picker and writing the config + all belong to the page, so the page injects them once. What lives here is + everything about how a row is drawn and when the list is rebuilt. + + The filter and the collapsed folders are in admin-state.js, because the page + reads and writes them too. */ + +import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e'; +import { snapshotItems } from '/js/admin-save-logic.js?v=48a9e055'; +import { reorderItems } from '/js/admin-logic.js?v=dcf7c37d'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=f9adcc10'; +import { paintIcon } from '/js/admin-shared.js?v=132c869f'; +import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=d949e985'; +import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; +import { t } from '/js/i18n.js?v=e644a5c5'; +import { sizeLabel } from '/js/admin-widget-form.js?v=db272c65'; +import { widgetGlyph } from '/js/widget-glyphs.js?v=12b0a947'; + +/** @type {{ openModal: (idx: number|null) => void, + openFolderPicker: (appId: string|null, targetFolderId?: string|null) => void, + save: (before: unknown) => Promise|boolean }} */ +let _page = { + openModal: () => {}, + openFolderPicker: () => {}, + save: () => false, +}; + +/** What the page owns and the list only triggers. Call once at start-up. + @param {typeof _page} page @returns {void} */ +export function initList(page) { + _page = page; + initDrag(page.save); +} + +/* Constant markup only. No user data reaches these. */ +const FOLDER_ICON = + ''; +const SIZE_ICONS = { + small: + '', + medium: + '', + large: + '', + xlarge: + '', +}; +function svgNode(markup) { + const t = document.createElement('template'); + setHtml(t, raw(markup)); + return t.content.firstElementChild; +} + +function moveRow(item, dir, opts = {}) { + const before = snapshotItems(state.items); + if (reorderItems(state.items, item, dir, opts)) _page.save(before); +} + +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; + if (item.type === 'folder') row.dataset.isFolder = '1'; + if (indent) { + row.dataset.indent = '1'; + row.dataset.folderId = folderId; + row.dataset.childIdx = String(childIdx); + } + let canUp = false, + canDown = false; + if (folderId != null) { + const cf = state.items.find(i => i.id === folderId); + const n = (cf?.children || []).length; + canUp = childIdx > 0; + canDown = childIdx < n - 1; + } else { + const inF = new Set(state.items.filter(i => i.type === 'folder').flatMap(ff => ff.children || [])); + const top = state.items.filter(it => it.type === 'folder' || !inF.has(it.id)); + const p = top.indexOf(item); + canUp = p > 0; + canDown = p < top.length - 1; + } + const handle = document.createElement('div'); + handle.className = 'rord'; + handle.textContent = '⠿'; + handle.setAttribute('aria-hidden', 'true'); + if (_filtering) handle.style.visibility = 'hidden'; + const ico = document.createElement('div'); + ico.className = 'rico'; + ico.style.background = rc(item.color); + if (item.type === 'folder') { + ico.appendChild(svgNode(FOLDER_ICON)); + } else if (item.type === 'widget') { + /* The type when the widget declares one, the size otherwise. */ + 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(); + const inf = document.createElement('div'); + inf.className = 'rinf'; + const isFolderRow = item.type === 'folder'; + const nm = document.createElement(isFolderRow ? 'button' : 'div'); + nm.className = 'rnm'; + if (isFolderRow) { + const collapsed = collapsedFolders.has(item.id); + nm.setAttribute('type', 'button'); + nm.style.cssText = 'display:flex;align-items:center;gap:6px;'; + nm.setAttribute('aria-expanded', String(!collapsed)); + nm.setAttribute('aria-label', t(collapsed ? 'folder.expandAria' : 'folder.collapseAria', { name: item.label })); + const chevron = document.createElement('span'); + chevron.style.cssText = 'font-size:10px;color:var(--dm);transition:transform .15s;flex-shrink:0;'; + chevron.textContent = '▼'; + chevron.style.transform = collapsed ? 'rotate(-90deg)' : 'rotate(0deg)'; + chevron.id = 'chev-' + item.id; + nm.append(chevron, document.createTextNode(item.label)); + nm.onclick = e => { + e.stopPropagation(); + if (collapsedFolders.has(item.id)) { + collapsedFolders.delete(item.id); + } else { + collapsedFolders.add(item.id); + } + render(); + }; + } else { + setUserText(nm, item.label || item.id); + } + const mt = document.createElement('div'); + mt.className = 'rmt'; + if (item.type === 'widget') { + const wt = item.widgetType || 'custom'; + const wtLabel = state._widgetReg?.[wt]?.label || 'Custom'; + 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'); + else mt.textContent = item.href || ''; + inf.append(nm, mt); + const pb = document.createElement('div'); + pb.className = 'rpills'; + const pills = []; + if (item.dock) pills.push(html`${t('app.dockPill')}`); + if (item.type === 'widget') pills.push(html`${t('type.widget')}`); + if (item.type === 'folder') pills.push(html`${t('type.folder')}`); + if (item.monitoring?.healthcheck?.enabled || item.container) + pills.push(html`${t('app.healthPill')}`); + if (item.monitoring?.activity?.enabled || item.badge?.enabled) + pills.push(html`${t('app.badgePill')}`); + if (item.system === 'settings') pills.push(html`${t('pill.system')}`); + if (item.hidden) pills.push(html`${t('pill.hidden')}`); + setHtml(pb, html`${pills}`); + const ac = document.createElement('div'); + ac.className = 'ract'; + const mkMove = (dir, can) => { + const b = document.createElement('button'); + b.className = 'btn bg sm ic'; + const lbl = t(dir < 0 ? 'common.moveUp' : 'common.moveDown'); + b.title = lbl; + b.setAttribute('aria-label', lbl + ': ' + (item.label || item.id || t('type.app'))); + b.textContent = dir < 0 ? '↑' : '↓'; + b.disabled = !can; + b.onclick = () => moveRow(item, dir, { folderId, childIdx }); + return b; + }; + if (!_filtering) ac.append(mkMove(-1, canUp), mkMove(1, canDown)); + if (item.system === 'settings') { + const hb = document.createElement('button'); + hb.className = 'btn bg sm'; + hb.textContent = t(item.hidden ? 'common.show' : 'common.hide'); + const lbl = t(item.hidden ? 'general.showSettingsAria' : 'general.hideSettingsAria'); + hb.title = lbl; + hb.setAttribute('aria-label', lbl); + hb.onclick = () => { + const before = snapshotItems(state.items); + item.hidden = !item.hidden; + _page.save(before); + }; + ac.append(hb); + } else { + const ed = document.createElement('button'); + ed.className = 'btn bg sm'; + ed.textContent = t('common.edit'); + ed.onclick = () => _page.openModal(idx); + ac.append(ed); + } + row.append(handle, ico, inf, pb, ac); + wireRowDrag(row, handle, { item, indent, folderId, childIdx }); + return row; +} + +export function render() { + const l = el('al'); + const bar = el('al-filter'); + const grp = el('al-grp'); + if (bar) { + if (state.items.length >= 6) bar.classList.remove('d-none'); + else { + bar.classList.add('d-none'); + if (filter.q || filter.type !== 'all') { + filter.q = ''; + filter.type = 'all'; + syncFilterUI(); + } + } + } + if (grp) grp.classList.toggle('d-none', !state.items.length); + if (!state.items.length) { + setHtml(l, html`

${t('list.empty')}

`); + return; + } + l.replaceChildren(); + if (filter.q || filter.type !== 'all') { + const q = filter.q.toLowerCase(); + const matches = state.items.filter(it => { + if (filter.type !== 'all' && it.type !== filter.type) return false; + if (q) { + const hay = ((it.label || '') + ' ' + (it.href || '') + ' ' + (it.widgetType || '')).toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); + if (!matches.length) { + setHtml(l, html`

${t('list.noMatches')}

`); + return; + } + matches.forEach(item => l.appendChild(mkRow(item, state.items.indexOf(item)))); + return; + } + const inFolder = new Set(state.items.filter(i => i.type === 'folder').flatMap(f => f.children || [])); + state.items.forEach((item, idx) => { + if (item.type !== 'folder' && inFolder.has(item.id)) return; + l.appendChild(mkRow(item, idx)); + if (item.type === 'folder' && !collapsedFolders.has(item.id)) { + (item.children || []).forEach((childId, ci) => { + const childItem = state.items.find(i => i.id === childId); + if (!childItem) return; + l.appendChild( + mkRow(childItem, state.items.indexOf(childItem), { indent: true, childIdx: ci, folderId: item.id }), + ); + }); + const addRow = document.createElement('button'); + addRow.type = 'button'; + addRow.className = 'fp-add'; + setHtml(addRow, html`+ ${t('folder.addAppToFolder')}`); + addRow.onclick = () => _page.openFolderPicker(null, item.id); + l.appendChild(addRow); + } + }); +} +export function syncFilterUI() { + const s = /** @type {HTMLInputElement} */ (el('al-search')); + if (s) s.value = filter.q; + qa('#al-filter .chip').forEach(c => { + const on = c.dataset.flt === filter.type; + c.classList.toggle('on', on); + c.setAttribute('aria-pressed', String(on)); + }); +} diff --git a/public/js/admin-logic.js b/public/js/admin-logic.js index e81bf8d..c15d798 100644 --- a/public/js/admin-logic.js +++ b/public/js/admin-logic.js @@ -272,3 +272,33 @@ export function toastHoldMs(kind, message, phase) { if (kind === 'err') return null; return phase === 'release' ? TOAST_RELEASE_MS : toastMs(message); } + +/** True when the address names a host only, with no path to read data from. + A bare address is accepted with a scheme, without one, and with a port, + a trailing slash, or a query string. + + @param {string} url + @returns {boolean} */ +export function isBareHostUrl(url) { + const raw = typeof url === 'string' ? url.trim() : ''; + if (!raw) return false; + let parsed; + try { + parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : 'http://' + raw); + } catch { + return false; + } + return parsed.pathname === '/'; +} + +/** Whether a failed badge fetch should report the missing API path instead of + the failure itself. A session that has expired is reported as itself: the + address is not why the request failed. + + @param {string} url + @param {{ sessionExpired?: boolean }} advice + @returns {boolean} */ +export function failureIsMissingApiPath(url, advice) { + if (advice && advice.sessionExpired) return false; + return isBareHostUrl(url); +} diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index d331ccc..cb6c88f 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,8 +1,9 @@ -import { toast, ag, ap } from '/js/admin-shared.js?v=1d330931'; +import { toast, ag, ap } from '/js/admin-shared.js?v=132c869f'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; -import { t } from '/js/i18n.js?v=83239bf4'; -import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=d17394da'; -import { el, inp, q, qa, setUserText } from '/js/utils.js?v=8ca7ce3c'; +import { t } from '/js/i18n.js?v=e644a5c5'; +import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=dcf7c37d'; +import { confirmText } from '/js/modal.js?v=11fa1eff'; +import { el, inp, q, qa, setUserText } from '/js/utils.js?v=d949e985'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ let _passwordSet = false; @@ -23,7 +24,7 @@ function syncSessionRows() { const canRevoke = on && _passwordSet; el('sec-revoke-row')?.classList.toggle('d-none', !canRevoke); const revokeTip = el('revoke-tip'); - if (revokeTip) revokeTip.style.display = canRevoke ? '' : 'none'; + if (revokeTip) revokeTip.classList.toggle('d-none', !canRevoke); } export function loadSettings(c) { @@ -184,7 +185,7 @@ export function loadSettings(c) { const socketRow = el('ie-socket'); if (socketRow) socketRow.classList.toggle('d-none', !v); const socketHint = el('socket-hint'); - if (socketHint) socketHint.style.display = v ? '' : 'none'; + if (socketHint) socketHint.classList.toggle('d-none', !v); }; applyDocker(dockerEnEl.checked); dockerEnEl.addEventListener('change', () => applyDocker(dockerEnEl.checked)); @@ -201,7 +202,14 @@ export function loadSettings(c) { location.reload(); }); secRevoke?.addEventListener('click', async () => { - if (!confirm(t('confirm.revokeSessions'))) return; + const ok = await confirmText({ + title: t('general.signOutEverywhere'), + text: t('confirm.revokeSessions'), + confirmLabel: t('general.signOutEverywhereBtn'), + cancelLabel: t('common.cancel'), + destructive: true, + }); + if (!ok) return; secRevoke.disabled = true; try { await ap('/api/auth/revoke-sessions', {}); @@ -233,7 +241,7 @@ async function syncAuthFromServer() { const pwRow = el('ie-pw'); const pwHint = el('pw-hint-static'); if (pwRow) pwRow.classList.toggle('d-none', !d.enabled); - if (pwHint) pwHint.style.display = d.enabled ? '' : 'none'; + if (pwHint) pwHint.classList.toggle('d-none', !d.enabled); } const pwValEl = el('ie-pw-v'); if (pwValEl) pwValEl.textContent = d.passwordSet ? t('common.configured') : t('common.notSet'); @@ -380,12 +388,18 @@ async function saveServer() { /* Switching protection off deletes the stored password. Ask before anything is written. */ - if ( - clearsStoredPassword({ enabled, wasEnabled: _authEnabled, passwordSet: _passwordSet }) && - !confirm(t('confirm.clearPassword')) - ) { - await syncAuthFromServer(); - return; + if (clearsStoredPassword({ enabled, wasEnabled: _authEnabled, passwordSet: _passwordSet })) { + const ok = await confirmText({ + title: t('general.passwordProtection'), + text: t('confirm.clearPassword'), + confirmLabel: t('common.delete'), + cancelLabel: t('common.cancel'), + destructive: true, + }); + if (!ok) { + await syncAuthFromServer(); + return; + } } try { diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index 3b49bb1..9678e50 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,7 +1,8 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=d17394da'; -import { el, qa, q } from '/js/utils.js?v=8ca7ce3c'; -import { t } from '/js/i18n.js?v=83239bf4'; +import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=dcf7c37d'; +import { el, qa, q } from '/js/utils.js?v=d949e985'; +import { t } from '/js/i18n.js?v=e644a5c5'; +import { iconChain } from '/js/icons.js?v=69c2b9bd'; export const API = ''; @@ -218,14 +219,28 @@ export function wireChecklist(dd, btn, list, onToggle) { }); o[active].focus(); }; + /* Scoped to the open state. A listener that outlives the list holds the + detached subtree it closes over, and the settings form rewires on every + render. */ + let outside = null; const open = () => { list.hidden = false; btn.setAttribute('aria-expanded', 'true'); + outside = new AbortController(); + document.addEventListener( + 'click', + e => { + if (!dd.contains(e.target)) close(); + }, + { signal: outside.signal }, + ); const o = opts(); const first = o.findIndex(li => li.getAttribute('aria-selected') === 'true'); setActive(first >= 0 ? first : 0); }; const close = ({ focusBtn = false } = {}) => { + outside?.abort(); + outside = null; list.hidden = true; btn.setAttribute('aria-expanded', 'false'); opts().forEach(li => li.classList.remove('kb-active')); @@ -278,11 +293,39 @@ export function wireChecklist(dd, btn, list, onToggle) { } }); - document.addEventListener('click', e => { - if (!dd.contains(e.target)) close(); - }); opts().forEach(li => { li.tabIndex = -1; }); return { close }; } + +/* An icon name resolves to several candidate URLs, and only the browser can + say which one exists. Walk them on error and fall back to a letter, or a + name that resolves to nothing leaves a broken image behind. + + Shared because it was not: the folder picker carried its own copy with no + fallback at all, and every app whose first candidate missed showed broken. + + @param {HTMLElement} host @param {string} [rawIcon] @param {string} [fallbackText] + @param {string} [imgCss] @returns {void} */ +export function paintIcon(host, rawIcon, fallbackText = '?', imgCss = '') { + const letter = () => { + host.textContent = fallbackText; + }; + const candidates = rawIcon ? iconChain(rawIcon) : []; + if (!candidates.length) return letter(); + const img = document.createElement('img'); + img.alt = ''; + if (imgCss) img.style.cssText = imgCss; + let at = 0; + img.onerror = () => { + at++; + if (at < candidates.length) img.src = candidates[at]; + else { + img.remove(); + letter(); + } + }; + img.src = candidates[0]; + host.appendChild(img); +} diff --git a/public/js/admin-state.js b/public/js/admin-state.js index f94f85e..4a21247 100644 --- a/public/js/admin-state.js +++ b/public/js/admin-state.js @@ -34,3 +34,9 @@ export const state = { _autoFormSession: null, _evSession: 0, }; + +/* Which folders are collapsed, and what the list is filtered to. Both the page + and the list read and write these, so they live here rather than in either. */ +export const collapsedFolders = new Set(); + +export const filter = { q: '', type: 'all' }; diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index efab3e7..10f9cb1 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,10 +1,10 @@ -import { state } from '/js/admin-state.js?v=c23e6346'; -import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=1d330931'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=77017460'; +import { state } from '/js/admin-state.js?v=7d68e98e'; +import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=132c869f'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=ccaf1acf'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=d17394da'; -import { t } from '/js/i18n.js?v=83239bf4'; -import { q, qi, qa } from '/js/utils.js?v=8ca7ce3c'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=dcf7c37d'; +import { t } from '/js/i18n.js?v=e644a5c5'; +import { q, qi, qa } from '/js/utils.js?v=d949e985'; const SIZE_ICONS = { small: @@ -21,7 +21,15 @@ const CUSTOM_SIZES = ['small', 'medium', 'large', 'xlarge']; function widgetSizes(type) { return type === 'custom' ? CUSTOM_SIZES : state._widgetReg[type]?.sizes || ['medium']; } -const SIZE_LABELS = { small: 'Small', medium: 'Medium', large: 'Large', xlarge: 'Extra Large' }; +const SIZE_KEYS = { + small: 'widgetCfg.sizeSmall', + medium: 'widgetCfg.sizeMedium', + large: 'widgetCfg.sizeLarge', + xlarge: 'widgetCfg.sizeXLarge', +}; + +/* A registry widget may declare a size that is not one of the four. */ +export const sizeLabel = size => (SIZE_KEYS[size] ? t(SIZE_KEYS[size]) : size); export function buildWidgetForm(body, item) { const wt = item?.widgetType || 'custom'; @@ -46,7 +54,7 @@ function _renderWidgetForm(body) { state._wAutoCfg = Object.assign({}, state._wAutoCfg, state._autoForm.getValues()); } state._autoForm = null; - body.innerHTML = ''; + body.replaceChildren(); const typeList = [...Object.values(state._widgetReg).map(w => [w.name, w.label]), ['custom', 'Custom']].sort((a, b) => a[1].localeCompare(b[1]), @@ -59,7 +67,7 @@ function _renderWidgetForm(body) { setHtml( shell, html` -
${t('widgetCfg.name')}${state._wlabel ? state._wlabel : t('widgetCfg.namePh')}
+
${t('widgetCfg.name')}${state._wlabel ? state._wlabel : t('widgetCfg.namePh')}
${t('widgetCfg.type')}
${raw(CHEV_SVG)}
`, ); body.appendChild(shell); @@ -95,7 +103,7 @@ function _renderWidgetForm(body) { scard.className = 'grp'; setHtml( scard, - html`
${_sizeOpts.map(s => html``)}
`, + html`
${_sizeOpts.map(s => html``)}
`, ); body.appendChild(scard); qa('.tile-opt', scard).forEach(b => @@ -170,7 +178,7 @@ function _renderCustomConfig(body) { body.appendChild(card); setHtml( card, - html`
${t('widgetCfg.iframeUrl')} *${state._customUrl ? state._customUrl : 'https://app.example.com/widget.html'}
`, + html`
${t('widgetCfg.iframeUrl')} *${state._customUrl ? state._customUrl : 'https://app.example.com/widget.html'}
`, ); const tip = document.createElement('p'); tip.className = 'grp-tip'; @@ -206,9 +214,9 @@ function _renderCustomConfig(body) { adv, html`
${t('widgetCfg.referrerPolicy')}
${raw(CHEV_SVG)}
-
${t('widgetCfg.allowFeaturePolicy')}${o.allow ? o.allow : 'autoplay; fullscreen'}
+
${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 : 'e.g. 2000'}
`, ); const sync = () => { state._iframeOpts.referrerPolicy = qi('#if-referrer', adv).value || undefined; diff --git a/public/js/admin.js b/public/js/admin.js index 15785d5..2127cc4 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,7 +1,7 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=1d7010c3'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=d1c7d163'; -import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=ebe3e806'; -import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=d17394da'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=5702feef'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9fc96f28'; +import { initList, render, syncFilterUI } from '/js/admin-list.js?v=cea8d7ce'; +import { resolveAdminSection } from '/js/admin-logic.js?v=dcf7c37d'; import { buildAppItem, claimFolderChildren, @@ -10,13 +10,13 @@ import { snapshotItems, upsertItem, } from '/js/admin-save-logic.js?v=48a9e055'; -import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=f5343e07'; -import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=1d330931'; -import { state } from '/js/admin-state.js?v=c23e6346'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=c549d05d'; +import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=f019211c'; +import { ag, ap, initInlineEdit, paintIcon, setReauthHandler, toast } from '/js/admin-shared.js?v=132c869f'; +import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=db272c65'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=83239bf4'; -import { iconChain, loadLocalIcons, resolveIcon } from '/js/icons.js?v=69c2b9bd'; +import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=e644a5c5'; +import { loadLocalIcons } from '/js/icons.js?v=69c2b9bd'; import { clearSkipTls, convert, @@ -26,13 +26,13 @@ import { parseErrorsAsSkipped, SKIP, } from '/js/import-foreign.js?v=ef4f3d44'; -import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; -import { confirmModal, openModal as openDialog, promptModal } from '/js/modal.js?v=ff76dc56'; -import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=db4192cd'; -import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=8ca7ce3c'; -import { widgetGlyph } from '/js/widget-glyphs.js?v=b5036986'; -import { normalizeColorInput } from '/js/admin-color-control.js?v=bfd0955a'; +import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=9de1cb7d'; +import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff'; +import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=00c011c9'; +import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=d949e985'; +import { normalizeColorInput } from '/js/admin-color-control.js?v=3a61c02f'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=1907cce7'; +import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; /* A class rather than a bare media query. Some phones report a wider CSS viewport than they have. The rule lives in layout.js, shared with the @@ -44,9 +44,6 @@ const _mobileAtLoad = isMobileLayout(); _syncMobile(_mobileAtLoad); onLayoutChange(_syncMobile, _mobileAtLoad); -const collapsedFolders = new Set(); /* tracks which folder ids are collapsed */ -let _flt = { q: '', type: 'all' }; - async function load() { await loadLocalIcons(); const c = await ag('/api/config'); @@ -89,17 +86,23 @@ async function applyBg() { root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); root.style.setProperty('--bg-size', bg.fit === 'fit' ? 'contain' : 'cover'); } else if (bg.type === 'unsplash') { - const r = await fetch('/api/wallpaper', { cache: 'no-store' }); - const d = await r.json(); - if (d.url) { + let url = loadWallpaper(bg); + if (!url) { + const r = await fetch('/api/wallpaper', { cache: 'no-store' }); + const d = await r.json(); + url = d.url || null; + if (url) saveWallpaper(url, bg); + } + if (url) { + const shown = url; const img = new Image(); img.onload = () => { - root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(d.url)}')`); + root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(shown)}')`); root.style.setProperty('--bg-color', '#0d1117'); root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); root.style.setProperty('--bg-size', 'cover'); }; - img.src = d.url; + img.src = shown; } } } catch {} @@ -113,10 +116,10 @@ async function save() { const full = await ag('/api/config'); full.items = state.items; await ap('/api/config', full); - toast('Saved'); + toast(t('toast.saved')); ok = true; } catch (e) { - toast('Save failed: ' + e.message, 'err'); + toast(t('toast.saveFailed', { err: e.message }), 'err'); } state.saving = false; render(); @@ -163,454 +166,13 @@ async function saveOrRevert(before) { } } -function moveRow(item, dir, opts = {}) { - const before = snapshotItems(state.items); - if (reorderItems(state.items, item, dir, opts)) saveOrRevert(before); -} - -/* Constant markup only. No user data reaches these. */ -const FOLDER_ICON = - ''; -const SIZE_ICONS = { - small: - '', - medium: - '', - large: - '', - xlarge: - '', -}; -function svgNode(markup) { - const t = document.createElement('template'); - setHtml(t, raw(markup)); - return t.content.firstElementChild; -} - -/* dataTransfer is not readable during dragover. */ -let _dragType = null; - -function clearDragClasses(target) { - const rows = target ? [target] : qa('.row'); - rows.forEach(r => { - r.classList.remove('drag-above', 'drag-below', 'drag-into', 'drag-over'); - }); -} - -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 = !!(_flt.q || _flt.type !== 'all'); - row.draggable = !_filtering; - row.dataset.itemId = item.id; - if (item.type === 'folder') row.dataset.isFolder = '1'; - if (indent) { - row.dataset.indent = '1'; - row.dataset.folderId = folderId; - row.dataset.childIdx = String(childIdx); - } - let canUp = false, - canDown = false; - if (folderId != null) { - const cf = state.items.find(i => i.id === folderId); - const n = (cf?.children || []).length; - canUp = childIdx > 0; - canDown = childIdx < n - 1; - } else { - const inF = new Set(state.items.filter(i => i.type === 'folder').flatMap(ff => ff.children || [])); - const top = state.items.filter(it => it.type === 'folder' || !inF.has(it.id)); - const p = top.indexOf(item); - canUp = p > 0; - canDown = p < top.length - 1; - } - const handle = document.createElement('div'); - handle.className = 'rord'; - handle.textContent = '⠿'; - handle.setAttribute('aria-hidden', 'true'); - if (_filtering) handle.style.visibility = 'hidden'; - const ico = document.createElement('div'); - ico.className = 'rico'; - ico.style.background = rc(item.color); - if (item.type === 'folder') { - ico.appendChild(svgNode(FOLDER_ICON)); - } else if (item.type === 'widget') { - /* The type when the widget declares one, the size otherwise. */ - const glyph = widgetGlyph(state._widgetReg?.[item.widgetType]?.glyph); - ico.appendChild(svgNode(glyph || SIZE_ICONS[item.widgetSize] || SIZE_ICONS.medium)); - } else if (item.iconUrl) { - const img = document.createElement('img'); - img.alt = item.label || ''; - img.style.cssText = 'width:28px;height:28px;object-fit:contain;'; - const fbs = iconChain(item.iconUrl); - if (fbs.length) { - let s = 0; - img.onerror = () => { - s++; - if (s < fbs.length) img.src = fbs[s]; - else { - ico.textContent = (item.label || '?')[0].toUpperCase(); - } - }; - img.src = fbs[0]; - ico.appendChild(img); - } else { - ico.textContent = (item.label || '?')[0].toUpperCase(); - } - } else ico.textContent = (item.label || item.id || '?')[0].toUpperCase(); - const inf = document.createElement('div'); - inf.className = 'rinf'; - const nm = document.createElement('div'); - nm.className = 'rnm'; - if (item.type === 'folder') { - const collapsed = collapsedFolders.has(item.id); - nm.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;'; - nm.setAttribute('role', 'button'); - nm.setAttribute('tabindex', '0'); - nm.setAttribute('aria-expanded', String(!collapsed)); - nm.setAttribute('aria-label', t(collapsed ? 'folder.expandAria' : 'folder.collapseAria', { name: item.label })); - const chevron = document.createElement('span'); - chevron.style.cssText = 'font-size:10px;color:var(--dm);transition:transform .15s;flex-shrink:0;'; - chevron.textContent = '▼'; - chevron.style.transform = collapsed ? 'rotate(-90deg)' : 'rotate(0deg)'; - chevron.id = 'chev-' + item.id; - nm.append(chevron, document.createTextNode(item.label)); - nm.onclick = e => { - e.stopPropagation(); - if (collapsedFolders.has(item.id)) { - collapsedFolders.delete(item.id); - } else { - collapsedFolders.add(item.id); - } - render(); - }; - nm.onkeydown = e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - nm.onclick(/** @type {any} */ (e)); - } - }; - } else { - setUserText(nm, item.label || item.id); - } - const mt = document.createElement('div'); - mt.className = 'rmt'; - if (item.type === 'widget') { - const wt = item.widgetType || 'custom'; - const wtLabel = state._widgetReg?.[wt]?.label || 'Custom'; - mt.textContent = `${wtLabel} widget · ${item.widgetSize || 'medium'}`; - } else if (item.type === 'folder') mt.textContent = `${(item.children || []).length} apps`; - else if (item.system === 'settings') mt.textContent = t('home.opensSettings'); - else mt.textContent = item.href || ''; - inf.append(nm, mt); - const pb = document.createElement('div'); - pb.className = 'rpills'; - const pills = []; - if (item.dock) pills.push(html`${t('app.dockPill')}`); - if (item.type === 'widget') pills.push(html`${t('type.widget')}`); - if (item.type === 'folder') pills.push(html`${t('type.folder')}`); - if (item.monitoring?.healthcheck?.enabled || item.container) - pills.push(html`${t('app.healthPill')}`); - if (item.monitoring?.activity?.enabled || item.badge?.enabled) - pills.push(html`${t('app.badgePill')}`); - if (item.system === 'settings') pills.push(html`System`); - if (item.hidden) pills.push(html`Hidden`); - setHtml(pb, html`${pills}`); - const ac = document.createElement('div'); - ac.className = 'ract'; - const mkMove = (dir, can) => { - const b = document.createElement('button'); - b.className = 'btn bg sm ic'; - const lbl = t(dir < 0 ? 'common.moveUp' : 'common.moveDown'); - b.title = lbl; - b.setAttribute('aria-label', lbl + ': ' + (item.label || item.id || 'item')); - b.textContent = dir < 0 ? '↑' : '↓'; - b.disabled = !can; - b.onclick = () => moveRow(item, dir, { folderId, childIdx }); - return b; - }; - if (!_filtering) ac.append(mkMove(-1, canUp), mkMove(1, canDown)); - if (item.system === 'settings') { - const hb = document.createElement('button'); - hb.className = 'btn bg sm'; - hb.textContent = t(item.hidden ? 'common.show' : 'common.hide'); - const lbl = t(item.hidden ? 'general.showSettingsAria' : 'general.hideSettingsAria'); - hb.title = lbl; - hb.setAttribute('aria-label', lbl); - hb.onclick = () => { - const before = snapshotItems(state.items); - item.hidden = !item.hidden; - saveOrRevert(before); - }; - ac.append(hb); - } else { - const ed = document.createElement('button'); - ed.className = 'btn bg sm'; - ed.textContent = t('common.edit'); - ed.onclick = () => openModal(idx); - ac.append(ed); - } - row.append(handle, ico, inf, pb, ac); - const dragData = indent ? 'child:' + folderId + ':' + item.id : 'top:' + item.id; - - row.addEventListener('dragstart', e => { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', dragData); - _dragType = item.type; - requestAnimationFrame(() => row.classList.add('dragging')); - }); - row.addEventListener('dragend', () => { - row.classList.remove('dragging'); - _dragType = null; - clearDragClasses(); - }); - row.addEventListener('dragover', e => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - clearDragClasses(); - const rect = row.getBoundingClientRect(); - if (row.dataset.isFolder && canJoinFolder(_dragType)) { - const zone = folderRowZone(e.clientY, rect); - row.classList.add(zone === 'into' ? 'drag-into' : zone === 'above' ? 'drag-above' : 'drag-below'); - } else { - row.classList.add(e.clientY < rect.top + rect.height / 2 ? 'drag-above' : 'drag-below'); - } - }); - row.addEventListener('dragleave', e => { - if (!e.relatedTarget || !row.contains(/** @type {Node} */ (e.relatedTarget))) clearDragClasses(row); - }); - - row.addEventListener('drop', e => { - e.preventDefault(); - const dropAbove = row.classList.contains('drag-above'); - const dropInto = row.classList.contains('drag-into'); - clearDragClasses(); - const raw = e.dataTransfer.getData('text/plain'); - if (!raw) return; - const drop = parseDragData(raw); - if (!drop) return; - const before = snapshotItems(state.items); - if ( - applyDrop(state.items, { - ...drop, - targetId: item.id, - targetFolderId: folderId, - targetIsFolder: item.type === 'folder' && dropInto, - indent, - childIdx, - dropAbove, - }) - ) - saveOrRevert(before); - }); - - wireTouchDrag(row, handle, { indent, folderId }); - return row; -} - -/* Drag data formats: "top:itemId" or "child:folderId:itemId". */ -function parseDragData(raw) { - if (raw.startsWith('child:')) { - const [, sfId, sItemId] = raw.split(':'); - return { srcId: sItemId, srcFolderId: sfId }; - } - if (raw.startsWith('top:')) return { srcId: raw.slice(4), srcFolderId: null }; - return null; -} - -/* Native HTML5 drag does not fire from touch on mobile WebKit. The handle needs - touch-action:none (see admin.css) or starting on it scrolls the list. */ -function wireTouchDrag(row, handle, { indent, folderId }) { - handle.addEventListener('pointerdown', e => { - if (e.pointerType === 'mouse') return; - if (row.draggable === false) return; /* hidden while filtering */ - e.preventDefault(); - const srcId = row.dataset.itemId; - const startRect = row.getBoundingClientRect(); - const ghost = row.cloneNode(true); - ghost.className = 'row drow drag-ghost'; - ghost.style.width = startRect.width + 'px'; - ghost.style.left = startRect.left + 'px'; - ghost.style.top = startRect.top + 'px'; - document.body.appendChild(ghost); - row.classList.add('dragging'); - handle.setPointerCapture(e.pointerId); - const offY = e.clientY - startRect.top; - let hovered = null, - dropAbove = false, - dropInto = false, - scrollTimer = null; - const scroller = scrollParent(row); - - const place = (x, y) => { - ghost.style.top = y - offY + 'px'; - ghost.style.left = startRect.left + 'px'; - clearDragClasses(); - hovered = null; - const under = /** @type {HTMLElement} */ (document.elementFromPoint(x, y)); - const tr = /** @type {HTMLElement} */ (under && under.closest('.drow')); - if (!tr || tr === row || tr === ghost) return; - hovered = tr; - const r = tr.getBoundingClientRect(); - if (tr.dataset.isFolder && canJoinFolder(itemType(srcId))) { - const zone = folderRowZone(y, r); - dropInto = zone === 'into'; - dropAbove = zone === 'above'; - tr.classList.add(dropInto ? 'drag-into' : dropAbove ? 'drag-above' : 'drag-below'); - } else { - dropInto = false; - dropAbove = y < r.top + r.height / 2; - tr.classList.add(dropAbove ? 'drag-above' : 'drag-below'); - } - }; - const autoscroll = y => { - if (scrollTimer) { - clearInterval(scrollTimer); - scrollTimer = null; - } - const rect = - scroller === document.scrollingElement - ? { top: 0, bottom: window.innerHeight } - : scroller.getBoundingClientRect(); - const M = 52; - const up = y < rect.top + M, - dn = y > rect.bottom - M; - if (!up && !dn) return; - scrollTimer = setInterval(() => { - scrollByPx(scroller, up ? -12 : 12); - }, 16); - }; - const move = ev => { - place(ev.clientX, ev.clientY); - autoscroll(ev.clientY); - }; - const end = () => { - handle.removeEventListener('pointermove', move); - handle.removeEventListener('pointerup', up); - handle.removeEventListener('pointercancel', cancel); - if (scrollTimer) clearInterval(scrollTimer); - ghost.remove(); - row.classList.remove('dragging'); - clearDragClasses(); - }; - const up = () => { - const tr = hovered; - end(); - if (!tr) return; - const into = !!tr.dataset.isFolder && canJoinFolder(itemType(srcId)) && dropInto; - const before = snapshotItems(state.items); - const drop = applyDrop(state.items, { - srcId, - srcFolderId: indent ? folderId : null, - targetId: tr.dataset.itemId, - targetFolderId: tr.dataset.folderId || null, - targetIsFolder: into, - indent: !!tr.dataset.indent, - childIdx: tr.dataset.childIdx != null ? Number(tr.dataset.childIdx) : null, - dropAbove: into ? false : dropAbove, - }); - if (drop) saveOrRevert(before); - }; - const cancel = () => end(); - handle.addEventListener('pointermove', move); - handle.addEventListener('pointerup', up); - handle.addEventListener('pointercancel', cancel); - }); -} - -function itemType(id) { - return state.items.find(i => i.id === id)?.type || null; -} - -function scrollParent(el) { - for (let p = el.parentElement; p; p = p.parentElement) { - const oy = getComputedStyle(p).overflowY; - if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p; - } - return document.scrollingElement || document.documentElement; -} -function scrollByPx(scroller, dy) { - if (scroller === document.scrollingElement) window.scrollBy(0, dy); - else scroller.scrollTop += dy; -} - -function render() { - const l = el('al'); - const bar = el('al-filter'); - const grp = el('al-grp'); - if (bar) { - if (state.items.length >= 6) bar.style.display = ''; - else { - bar.style.display = 'none'; - if (_flt.q || _flt.type !== 'all') { - _flt = { q: '', type: 'all' }; - _syncFilterUI(); - } - } - } - if (grp) grp.style.display = state.items.length ? '' : 'none'; - if (!state.items.length) { - setHtml(l, html`

${t('list.empty')}

`); - return; - } - l.innerHTML = ''; - if (_flt.q || _flt.type !== 'all') { - const q = _flt.q.toLowerCase(); - const matches = state.items.filter(it => { - if (_flt.type !== 'all' && it.type !== _flt.type) return false; - if (q) { - const hay = ((it.label || '') + ' ' + (it.href || '') + ' ' + (it.widgetType || '')).toLowerCase(); - if (!hay.includes(q)) return false; - } - return true; - }); - if (!matches.length) { - setHtml(l, html`

${t('list.noMatches')}

`); - return; - } - matches.forEach(item => l.appendChild(mkRow(item, state.items.indexOf(item)))); - return; - } - const inFolder = new Set(state.items.filter(i => i.type === 'folder').flatMap(f => f.children || [])); - state.items.forEach((item, idx) => { - if (item.type !== 'folder' && inFolder.has(item.id)) return; - l.appendChild(mkRow(item, idx)); - if (item.type === 'folder' && !collapsedFolders.has(item.id)) { - (item.children || []).forEach((childId, ci) => { - const childItem = state.items.find(i => i.id === childId); - if (!childItem) return; - l.appendChild( - mkRow(childItem, state.items.indexOf(childItem), { indent: true, childIdx: ci, folderId: item.id }), - ); - }); - const addRow = document.createElement('button'); - addRow.type = 'button'; - addRow.className = 'fp-add'; - setHtml(addRow, html`+ ${t('folder.addAppToFolder')}`); - addRow.onclick = () => openFolderPicker(null, item.id); - l.appendChild(addRow); - } - }); -} -function _syncFilterUI() { - const s = inp('al-search'); - if (s) s.value = _flt.q; - qa('#al-filter .chip').forEach(c => { - const on = c.dataset.flt === _flt.type; - c.classList.toggle('on', on); - c.setAttribute('aria-pressed', String(on)); - }); -} - function showListView() { - el('dash-list-view').style.display = ''; - el('dash-edit-view').style.display = 'none'; + el('dash-list-view').classList.remove('d-none'); + el('dash-edit-view').classList.add('d-none'); } function showEditView() { - el('dash-list-view').style.display = 'none'; - el('dash-edit-view').style.display = ''; + el('dash-list-view').classList.add('d-none'); + el('dash-edit-view').classList.remove('d-none'); el('cp')?.scrollTo?.(0, 0); q('.cp')?.scrollTo?.(0, 0); } @@ -661,7 +223,7 @@ function buildAddNewCard() { /* Prepended after the builder runs, so the builder's reset cannot wipe it. */ function _renderEditBody() { const body = el('ev-body'); - body.innerHTML = ''; + body.replaceChildren(); if (state.ctype === 'widget') buildWidgetForm(body, state._evItem); else if (state.ctype === 'folder') buildFolderForm(body, state._evItem); else buildAppForm(body, state._evItem); @@ -735,11 +297,17 @@ function openModal(idx) { async function _evDelete(item, idx) { if (!item) return; - if (item.type === 'folder') { - if (!confirm(t('confirm.deleteFolder', { name: item.label }))) return; - } else { - if (!confirm(t('confirm.remove', { name: item.label || item.id }))) return; - } + const isFolder = item.type === 'folder'; + const ok = await confirmText({ + title: t('common.delete'), + text: isFolder + ? t('confirm.deleteFolder', { name: item.label }) + : t('confirm.remove', { name: item.label || item.id }), + confirmLabel: t('common.delete'), + cancelLabel: t('common.cancel'), + destructive: true, + }); + if (!ok) return; const before = snapshotItems(state.items); state.items.forEach(f => { if (f.type === 'folder') f.children = (f.children || []).filter(id => id !== item.id); @@ -751,13 +319,13 @@ async function _evDelete(item, idx) { const s = inp('al-search'); if (s) s.addEventListener('input', () => { - _flt.q = s.value.trim(); + filter.q = s.value.trim(); render(); }); qa('#al-filter .chip').forEach(c => { c.addEventListener('click', () => { - _flt.type = c.dataset.flt; - _syncFilterUI(); + filter.type = c.dataset.flt; + syncFilterUI(); render(); }); }); @@ -819,12 +387,7 @@ function openFolderPicker(appId, targetFolderId = null) { const ri = document.createElement('span'); ri.className = 'fp-ic'; ri.style.background = rc(app.color); - if (app.iconUrl) { - const img = document.createElement('img'); - img.alt = ''; - img.src = resolveIcon(app.iconUrl); - ri.appendChild(img); - } else ri.textContent = (app.label || '?')[0]; + paintIcon(ri, app.iconUrl, (app.label || '?')[0]); const nm = document.createElement('span'); nm.className = 'fp-nm'; setUserText(nm, app.label || app.id); @@ -914,7 +477,7 @@ async function doSave(orig) { if (state._autoForm && state._autoFormType === state._wtype && state._widgetReg[state._wtype]) { const missing = state._autoForm.validate(); if (missing.length) { - toast(missing[0] + ' is required', 'err'); + toast(t('toast.fieldRequired', { field: missing[0] }), 'err'); return; } item = { @@ -934,7 +497,7 @@ async function doSave(orig) { } else if (state._wtype === 'custom') { const url = inp('f-url')?.value?.trim(); if (!url) { - toast('URL required', 'err'); + toast(t('toast.urlRequired'), 'err'); return; } const ifo = {}; @@ -961,7 +524,7 @@ async function doSave(orig) { } else if (state.ctype === 'folder') { const label = inp('f-fname')?.value?.trim(); if (!label) { - toast('Name required', 'err'); + toast(t('toast.nameRequired'), 'err'); return; } /* An app belongs to one folder, or the dashboard renders it twice. */ @@ -1024,9 +587,9 @@ async function doSave(orig) { /* The editor stays open on a failed write, with the form intact. */ if (!(await saveOrRevert(before))) return; closeModal(); - toast(replaced ? 'Updated' : 'Added'); + toast(t(replaced ? 'toast.updated' : 'toast.added')); } catch (e) { - toast('Error: ' + e.message, 'err'); + toast(t('toast.error', { err: e.message }), 'err'); } } @@ -1071,8 +634,8 @@ function initAllInlineEdits() { onCommit() { const bars = el('sec-pw-bars'); const hint = el('sec-pw-hint'); - if (bars) bars.style.display = 'none'; - if (hint) hint.style.display = 'none'; + if (bars) bars.classList.add('d-none'); + if (hint) hint.classList.add('d-none'); }, }); const pwInp = el('sec-pw'); @@ -1082,12 +645,8 @@ function initAllInlineEdits() { () => { const bars = el('sec-pw-bars'); const hint = el('sec-pw-hint'); - if (bars) { - bars.style.display = 'flex'; - } - if (hint) { - hint.style.display = 'block'; - } + bars?.classList.remove('d-none'); + hint?.classList.remove('d-none'); wirePasswordStrength('sec-pw', 'sec-pw-bars', 'sec-pw-hint'); }, { once: true }, @@ -1142,7 +701,7 @@ async function initVersion() { if (aEl) aEl.textContent = t('about.version', { v }); if (d.updateAvailable) { const dot = el('about-update-dot'); - if (dot) dot.style.display = 'flex'; + dot?.classList.remove('d-none'); if (aEl && d.latest) { const lv = String(d.latest).replace(/^v/i, ''); setHtml( @@ -1169,7 +728,7 @@ function initSecToggle() { if (!en) return; function apply(on) { if (pwRow) pwRow.classList.toggle('d-none', !on); - if (pwHint) pwHint.style.display = on ? '' : 'none'; + if (pwHint) pwHint.classList.toggle('d-none', !on); } apply(en.checked); en.addEventListener('change', () => apply(en.checked)); @@ -1658,6 +1217,7 @@ el('imp-foreign').onchange = async e => { el('btn-add').onclick = () => openModal(null); +initList({ openModal, openFolderPicker, save: saveOrRevert }); initNav(); initAllInlineEdits(); initSecToggle(); @@ -1674,13 +1234,13 @@ setReauthHandler(requireLogin); checkAuth(load).then(ok => { if (!ok) return; load().catch(e => { - toast('Could not load config. Is the API container running? (' + e.message + ')', 'err'); + toast(t('toast.configLoadFailed', { err: e.message }), 'err'); const al = el('al'); if (al) { /* An inline onclick is blocked by the CSP. */ setHtml( al, - html`
Failed to load dashboard config.

`, + html`
${t('home.loadFailed')}

`, ); q('.retry-btn', al)?.addEventListener('click', () => location.reload()); } diff --git a/public/js/badge-logic.js b/public/js/badge-logic.js index 87bfb81..7a12df6 100644 --- a/public/js/badge-logic.js +++ b/public/js/badge-logic.js @@ -249,7 +249,9 @@ export function computeBadgeVisual({ place. NAMED does not hold those token values and never decided this. */ const color = bg ? (needsDark(bg) ? '#1c1c1e' : '#ffffff') : ''; - if (health) rows.push({ name: reason || tr('status.needsAttention'), value: '!', unit: '', color: NAMED.red }); + /* No value: the mark is what the badge already draws, and the row exists for + the reason beside it. */ + if (health) rows.push({ name: reason || tr('status.needsAttention'), value: '', unit: '', color: NAMED.red }); for (const f of fired) rows.push(f); if (fixed) rows.push({ name: staticBdg.label, value: '', unit: '', color: safeColor(staticBdg.color) }); @@ -260,7 +262,29 @@ export function computeBadgeVisual({ } const nextColor = more ? rows[1].color : ''; - return { cls, txt, num, unit, bg, aria, color, title: reason, more, nextColor, rows: more ? rows : [] }; + /* The popover exists to show what the badge cannot. Several values qualify, + and so does a failure, whose reason is otherwise only in the name. A single + healthy badge does not: it would repeat what is already drawn. */ + const popover = more > 0 || Boolean(health); + /* A badge ignores the pointer so a click reaches the tile under it. One that + has something to open must not, or it can never be hovered. Separate from + has-more, which draws the second pill and is about having extra values. */ + if (popover) cls += ' has-pop'; + + return { + cls, + txt, + num, + unit, + bg, + aria, + color, + title: reason, + more, + nextColor, + popover, + rows: popover ? rows : [], + }; } /** Identity of a rendered badge, for skipping DOM writes that change nothing. diff --git a/public/js/badge-popover.js b/public/js/badge-popover.js index 702d076..3ecc8ad 100644 --- a/public/js/badge-popover.js +++ b/public/js/badge-popover.js @@ -26,7 +26,10 @@ function ensurePop() { pop.className = 'badge-pop'; pop.id = 'badge-pop'; pop.setAttribute('role', 'tooltip'); - pop.hidden = true; + /* auto, so the browser gives the top layer, dismissal on a click outside and + Escape. It cannot position it: anchor positioning is Safari 26, well above + the support floor, so place() stays. */ + pop.setAttribute('popover', 'auto'); /* Interactive now, so a click on it must not reach the tile underneath. */ pop.addEventListener('pointerenter', cancelClose); pop.addEventListener('pointerleave', scheduleClose); @@ -39,7 +42,7 @@ function ensurePop() { control nested in a link is unreachable. @param {HTMLElement} badge @returns {HTMLElement|null} */ function tileOf(badge) { - return /** @type {HTMLElement|null} */ (badge.closest('a, [role="button"]')); + return /** @type {HTMLElement|null} */ (badge.closest('a, button, [role="button"]')); } /** @param {HTMLElement} badge */ @@ -81,7 +84,7 @@ function open(badge) { line.append(dot, name, val); p.appendChild(line); } - p.hidden = false; + p.showPopover(); openFor = badge; openedAt = Date.now(); place(badge); @@ -97,7 +100,7 @@ export function closeBadgePopover() { openTimer = 0; clearTimeout(closeTimer); closeTimer = 0; - if (pop) pop.hidden = true; + if (pop?.matches(':popover-open')) pop.hidePopover(); openFor = null; if (describedEl) { describedEl.removeAttribute('aria-describedby'); @@ -126,23 +129,20 @@ function cancelClose() { closeTimer = 0; } +/* Escape and a click outside are the browser's now. Scrolling and resizing are + not: light dismiss does not cover them, and the popover is positioned by hand + against a badge that has moved. */ let globalsWired = false; function wireGlobals() { if (globalsWired) return; globalsWired = true; - document.addEventListener('keydown', e => { - if (e.key === 'Escape' && openFor) closeBadgePopover(); - }); - window.addEventListener('scroll', () => closeBadgePopover(), true); + window.addEventListener('scroll', () => closeBadgePopover(), { capture: true, passive: true }); window.addEventListener('resize', () => closeBadgePopover()); - document.addEventListener( - 'pointerdown', - e => { - const target = /** @type {Node} */ (e.target); - if (openFor && pop && !pop.contains(target) && !openFor.contains(target)) closeBadgePopover(); - }, - true, - ); + /* The browser dismisses it without telling this module, which would then + think it is still open. */ + ensurePop().addEventListener('toggle', e => { + if (/** @type {any} */ (e).newState === 'closed' && openFor) closeBadgePopover(); + }); } /** Attach or remove the popover on one badge. Null rows remove it. diff --git a/public/js/dashboard-logic.js b/public/js/dashboard-logic.js index 0ed5f07..81d1916 100644 --- a/public/js/dashboard-logic.js +++ b/public/js/dashboard-logic.js @@ -19,41 +19,6 @@ function fingerprint(c) { return JSON.stringify(c?.items?.map(i => `${i?.id}|${i?.label}|${i?.href}`)) + JSON.stringify(c?.settings); } -/** One day. */ -const WALLPAPER_TTL_MS = 24 * 60 * 60 * 1000; - -/** @param {any} bg the background settings @returns {string} */ -function wallpaperKey(bg) { - return `${bg?.type || ''}|${bg?.collection || ''}`; -} - -/** The still-valid wallpaper URL held for these background settings, if any. - - @param {string|null} stored the raw stored entry - @param {any} bg the background settings the page was built from - @param {number} now - @returns {string|null} */ -export function readWallpaperCache(stored, bg, now) { - if (typeof stored !== 'string' || !stored) return null; - let entry; - try { - entry = JSON.parse(stored); - } catch { - return null; - } - if (!entry || typeof entry.url !== 'string' || !entry.url) return null; - if (entry.key !== wallpaperKey(bg)) return null; - if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null; - /* A clock moved backwards would otherwise hold one photo forever. */ - if (now < entry.at || now - entry.at >= WALLPAPER_TTL_MS) return null; - return entry.url; -} - -/** @param {string} url @param {any} bg @param {number} now @returns {string} */ -export function writeWallpaperCache(url, bg, now) { - return JSON.stringify({ url, key: wallpaperKey(bg), at: now }); -} - /** The page index to open on, clamped to the pages that exist. @param {string|null} stored @param {number} totalPages @returns {number} */ diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 92ca2d9..b75cd3b 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -8,7 +8,7 @@ import { widgetSrc, cardPreset, uniqueTitle, -} from '/js/widget-types.js?v=6a5e1619'; +} from '/js/widget-types.js?v=a1b61636'; import { el, mk, @@ -22,28 +22,22 @@ import { setUserText, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=8ca7ce3c'; -import { initSpotlight } from '/js/spotlight.js?v=fe9e1b53'; +} from '/js/utils.js?v=d949e985'; +import { initSpotlight } from '/js/spotlight.js?v=687fae26'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; -import { initI18n, t, currentLang } from '/js/i18n.js?v=83239bf4'; +import { initI18n, t, currentLang } from '/js/i18n.js?v=e644a5c5'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; -import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=88773f11'; -import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=41a929ac'; -import { formatNumber } from '/js/format-number.js?v=e2165e12'; -import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=08aae50f'; -import { - configChanged, - landingAfterSetup, - readWallpaperCache, - writeWallpaperCache, - restorePage, -} from '/js/dashboard-logic.js?v=a0604f3b'; -import { trapFocus } from '/js/dialog.js?v=05935547'; -import { jitter } from '/js/jitter.js?v=4edf48f2'; -import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; +import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=82e0bf6b'; +import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=b3c8b6c2'; +import { formatNumber } from '/js/format-number.js?v=4a5ccef4'; +import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3'; +import { configChanged, landingAfterSetup, restorePage } from '/js/dashboard-logic.js?v=74ffcb7e'; +import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; +import { jitter } from '/js/jitter.js?v=4eeef4c9'; +import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=9de1cb7d'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; -import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=38adb276'; +import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; /* Recomputed, never stored: the window can cross the breakpoint after load. */ let MOB = isMobileLayout(); @@ -105,8 +99,7 @@ let _lastTouch = 0; the boot veil up forever. Generous: this only has to beat a hang. */ const BOOT_TIMEOUT_MS = 15000; -const PAGE_STORE = 'dash_page', - WALLPAPER_STORE = 'dash_wallpaper'; +const PAGE_STORE = 'dash_page'; /** @param {string} key @returns {string|null} */ function storeGet(key) { @@ -168,7 +161,7 @@ function bupd(id) { const act = item?.monitoring?.activity || {}; const isFolder = item?.type === 'folder'; - const { cls, txt, num, unit, bg, aria, color, title, more, nextColor, rows } = computeBadgeVisual({ + const { cls, txt, num, unit, bg, aria, color, nextColor, popover, rows } = computeBadgeVisual({ health: s.health, activity: s.activity, activityStale: !!s.activityStale, @@ -185,7 +178,7 @@ function bupd(id) { format: formatNumber, }); - const sig = badgeSignature({ cls, txt, unit, bg, aria, color, title, nextColor, rows }); + const sig = badgeSignature({ cls, txt, unit, bg, aria, color, nextColor, rows }); els.forEach(el => { if (BSIG.get(el) === sig) return; @@ -210,7 +203,7 @@ function bupd(id) { el.setAttribute('aria-hidden', 'true'); el.removeAttribute('role'); el.removeAttribute('aria-label'); - const tile = /** @type {HTMLElement|null} */ (el.closest('a, [role="button"]')); + const tile = /** @type {HTMLElement|null} */ (el.closest('a, button, [role="button"]')); const tileName = tile?.dataset.tileName; if (tile && tileName) tile.setAttribute('aria-label', aria ? `${tileName}, ${aria}` : tileName); /* Never the `background` shorthand. It resets background-clip, and the @@ -220,11 +213,11 @@ function bupd(id) { el.style.color = color; if (nextColor) el.style.setProperty('--badge-next', nextColor); else el.style.removeProperty('--badge-next'); - wireBadgePopover(el, more ? rows : null); - /* Assigned, never interpolated. An upstream error string must not become - markup. */ - if (title) el.title = title; - else el.removeAttribute('title'); + /* No title attribute. The popover carries the reason, and a badge that can + be hovered would otherwise draw the browser's own tooltip beside it. + Removed rather than skipped: one set by an earlier paint would persist. */ + el.removeAttribute('title'); + wireBadgePopover(el, popover ? rows : null); }); } @@ -389,7 +382,7 @@ function buildDesktop() { const pages = paginate(); totalPages = pages.length; const strip = el('pages'); - strip.innerHTML = ''; + strip.replaceChildren(); pages.forEach(pageItems => { const p = mk('div'); p.className = 'page'; @@ -400,10 +393,10 @@ function buildDesktop() { strip.appendChild(p); }); const dots = el('dots'); - dots.innerHTML = ''; + dots.replaceChildren(); pages.forEach((_, i) => dots.appendChild(mkDot(i, pages.length, 0, goTo))); const dk = el('dock'); - dk.innerHTML = ''; + dk.replaceChildren(); dock.forEach(item => dk.appendChild(mkDock(item))); } @@ -492,7 +485,7 @@ function syncMobPages() { if (domCount <= totalPages) return; /* no overflow pages, nothing to fix */ totalPages = domCount; const dots = el('dots'); - dots.innerHTML = ''; + dots.replaceChildren(); for (let i = 0; i < domCount; i++) dots.appendChild(mkDot(i, domCount, pg, goTo)); const pillDots = q('.msp-dots'); if (pillDots) { @@ -576,12 +569,12 @@ async function applyBg() { root.style.setProperty('--bg-size', fit === 'fit' ? 'contain' : 'cover'); sampleWallpaper(url, brightness, fit); } else if (bg.type === 'unsplash') { - let url = readWallpaperCache(storeGet(WALLPAPER_STORE), bg, Date.now()); + let url = loadWallpaper(bg); if (!url) { const r = await fetch('/api/wallpaper', { cache: 'no-store' }); const d = await r.json(); url = d.url || null; - if (url) storeSet(WALLPAPER_STORE, writeWallpaperCache(url, bg, Date.now())); + if (url) saveWallpaper(url, bg); } if (url) { const shown = url; @@ -653,14 +646,13 @@ const EYE = function showSetupPrompt() { return new Promise(resolve => { - const ov = document.createElement('div'); + const ov = /** @type {HTMLDialogElement} */ (document.createElement('dialog')); ov.className = 'setup-prompt'; + ov.setAttribute('aria-labelledby', 'setup-title'); setHtml( ov, - html``, + html`

${t('setup.title')}

${t('setup.sub')}

`, ); - document.body.appendChild(ov); - const pw = qi('#setup-pw', ov); const pw2 = qi('#setup-pw2', ov); const rev = qi('#setup-reveal', ov); @@ -697,15 +689,17 @@ function showSetupPrompt() { rev.title = label; }; - let releaseTrap = trapFocus(ov, { closeOnEscape: false, initialFocus: pw }); - const close = () => { - if (releaseTrap) { - releaseTrap(); - releaseTrap = null; - } + document.body.appendChild(ov); + ov.showModal(); + pw.focus(); + /* Escape must not dismiss this: there is nothing usable behind it, and Skip + is the deliberate way past. */ + ov.addEventListener('cancel', e => e.preventDefault()); + ov.addEventListener('close', () => { ov.remove(); resolve(); - }; + }); + const close = () => ov.close(); skip.onclick = async () => { skip.disabled = true; @@ -950,17 +944,13 @@ async function boot() { and the mobile layout is measured. Debounced, and only while it is the layout in use: on a phone the keyboard opening resizes the viewport too. */ let _rt; - window.addEventListener( - 'orientationchange', - () => { - clearTimeout(_rt); - _rt = setTimeout(() => { - if (MOB) buildLayout(); - resampleBg(); - }, 150); - }, - { passive: true }, - ); + screen.orientation.addEventListener('change', () => { + clearTimeout(_rt); + _rt = setTimeout(() => { + if (MOB) buildLayout(); + resampleBg(); + }, 150); + }); /* The desktop tile size follows the viewport, so a resize can change how many rows fit. Rebuild only when the slot count actually moves, not on every diff --git a/public/js/dialog.js b/public/js/dialog.js deleted file mode 100644 index 7ad5bd9..0000000 --- a/public/js/dialog.js +++ /dev/null @@ -1,110 +0,0 @@ -// @ts-check -/* The page behind an overlay is still focusable while covered, so Tab must wrap - inside it. Escape and focus restoration belong with the trap. */ - -const FOCUSABLE = [ - 'a[href]', - 'button:not([disabled])', - 'input:not([disabled]):not([type="hidden"])', - 'select:not([disabled])', - 'textarea:not([disabled])', - '[tabindex]:not([tabindex="-1"])', -].join(','); - -/** The elements inside `root` that a Tab press can reach, in document order. - @param {Element} root @returns {HTMLElement[]} */ -export function focusableWithin(root) { - if (!root || typeof root.querySelectorAll !== 'function') return []; - return /** @type {HTMLElement[]} */ ([...root.querySelectorAll(FOCUSABLE)]).filter(el => { - /* offsetParent is null for a hidden element and for position:fixed. */ - const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null; - if (style && (style.visibility === 'hidden' || style.display === 'none')) return false; - if (el.offsetParent === null && (!style || style.position !== 'fixed')) return false; - return el.getAttribute('aria-hidden') !== 'true'; - }); -} - -/** Move focus to the far end when Tab would otherwise leave `root`. - Returns true when it handled the event. - @param {KeyboardEvent} e @param {Element} root @returns {boolean} */ -export function wrapTab(e, root) { - if (e.key !== 'Tab') return false; - const f = focusableWithin(root); - if (!f.length) return false; - const first = f[0], - last = f[f.length - 1]; - const active = document.activeElement; - - /* Focus outside the dialog, which happens when it opens with nothing - focused. */ - if (!root.contains(active)) { - e.preventDefault(); - first.focus(); - return true; - } - - if (e.shiftKey && active === first) { - e.preventDefault(); - last.focus(); - return true; - } - if (!e.shiftKey && active === last) { - e.preventDefault(); - first.focus(); - return true; - } - return false; -} - -/** Make `root` behave as a modal dialog until the returned function is called. - Calling that function twice is harmless. - - @param {Element} root - @param {{ onClose?: () => void, initialFocus?: HTMLElement | null, - closeOnEscape?: boolean }} [opts] - @returns {() => void} */ -export function trapFocus(root, opts = {}) { - if (!root) return () => {}; - const { onClose, initialFocus, closeOnEscape = true } = opts; - const restoreTo = /** @type {HTMLElement|null} */ (document.activeElement); - let released = false; - - const onKeydown = /** @param {KeyboardEvent} e */ e => { - if (wrapTab(e, root)) return; - if (closeOnEscape && e.key === 'Escape') { - e.preventDefault(); - release(); - if (onClose) onClose(); - } - }; - - function release() { - if (released) return; - released = true; - document.removeEventListener('keydown', /** @type {EventListener} */ (onKeydown), true); - /* Without this, closing leaves focus on nothing and the next Tab starts at - the top of the page. */ - if (restoreTo && restoreTo.focus && restoreTo.isConnected) { - try { - restoreTo.focus(); - } catch { - /* not focusable any more */ - } - } - } - - /* On the document, not on root. Clicking unfocusable text inside a dialog - moves focus to the body, where a listener on root never sees the key. */ - document.addEventListener('keydown', /** @type {EventListener} */ (onKeydown), true); - - const target = initialFocus || focusableWithin(root)[0]; - if (target && target.focus) { - try { - target.focus(); - } catch { - /* nothing to focus */ - } - } - - return release; -} diff --git a/public/js/format-number.js b/public/js/format-number.js index e4ebd0d..ffff7b9 100644 --- a/public/js/format-number.js +++ b/public/js/format-number.js @@ -1,15 +1,6 @@ // @ts-check -/* Digit shape follows the reader's locale, not the interface language. - - Apple's guidance is explicit that numerals take "the correct localized digits - for the user's locale and preferences", and that neither Arabic nor Persian - always uses native digits: it depends on the country, and the reader can - choose. An iPhone in Arabic with a Latin-numeral region shows Latin digits in - its badges. So the language setting picks the words and the locale picks the - digits. - - A number that identifies rather than counts, such as a version or a port, is - not passed through here: those are the same string in every language. */ +/* The language picks the words, the locale picks the digits. A number that + identifies rather than counts is not passed through here. */ /** @type {Intl.NumberFormat|null} */ let cached = null; @@ -48,8 +39,18 @@ export function formatNumber(value, options) { return cached.format(value); } +/* Ten entries, built once per locale from the same cached formatter. Formatting + each character separately called Intl on every digit of every polled string. */ +let digitMap = null; +let digitMapFor = null; + /** The digits of an already-formed string, for text built elsewhere. @param {string} text @returns {string} */ export function localiseDigits(text) { - return String(text).replace(/\d/g, d => formatNumber(Number(d))); + const loc = locale(); + if (!digitMap || digitMapFor !== loc) { + digitMap = Array.from({ length: 10 }, (_, d) => formatNumber(d)); + digitMapFor = loc; + } + return String(text).replace(/\d/g, d => digitMap[Number(d)]); } diff --git a/public/js/i18n.js b/public/js/i18n.js index 243baa6..8d94530 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -91,16 +91,8 @@ async function fetchCatalog(code) { } } -/* Two development locales. Neither is in the registry, so neither is offered in - the selector; both are reached by adding ?lang= to the page URL. - - PSEUDO_LANG rewrites the English catalog: it accents the letters, pads the - text by about 40% and brackets each message. Text that clips, wraps badly or - was concatenated from fragments then shows up on any screen, in a language - the reader can still read. - - KEY_LANG loads no catalog, so every string renders as its own key. It answers - "which key is this on screen" and "what here is not translated at all". */ +/* Development locales, reached by ?lang= and absent from the registry. + What each one does: docs/i18n.md. */ export const PSEUDO_LANG = 'en-XA'; export const KEY_LANG = 'cimode'; diff --git a/public/js/jitter.js b/public/js/jitter.js index 2e57ba5..caf7cea 100644 --- a/public/js/jitter.js +++ b/public/js/jitter.js @@ -1,7 +1,5 @@ // @ts-check -/* Spread repeated timers, so several pollers started together do not keep - firing on the same tick. Keep it dependency-free: it is imported inside - widget frames as well as by the dashboard. */ +/* Keep this module free of imports. It is loaded inside widget frames. */ const SPREAD = 0.15; diff --git a/public/js/label-contrast.js b/public/js/label-contrast.js index 14eee75..d1eb614 100644 --- a/public/js/label-contrast.js +++ b/public/js/label-contrast.js @@ -138,15 +138,15 @@ export function toneForRect(grid, cols, rows, vw, vh, rect) { /* ── The DOM half ─────────────────────────────────────────────────────────── */ -export const GRID_COLS = 32; -export const GRID_ROWS = 18; +const GRID_COLS = 32; +const GRID_ROWS = 18; /** Any CSS colour as an sRGB triple. A canvas keeps its previous fillStyle when the value does not parse, so two seeds are needed to tell an unreadable value from a real one. @param {string} value @returns {[number, number, number]|null} */ -export function parseCssColor(value) { +function parseCssColor(value) { if (typeof value !== 'string' || !value.trim()) return null; const read = (/** @type {string} */ seed) => { const cv = document.createElement('canvas'); diff --git a/public/js/layout.js b/public/js/layout.js index d6ca40c..dd0fb41 100644 --- a/public/js/layout.js +++ b/public/js/layout.js @@ -1,14 +1,6 @@ // @ts-check -/* Whether to show the mobile layout, as one rule for the dashboard and Admin. - - The width test is a media query, not a stored innerWidth, so it answers the - same question the stylesheets ask. The user-agent test covers phones that - report a wider CSS viewport than 768px, where a width query alone leaves a - phone on the desktop layout; it is qualified by portrait so a phone held - sideways gets the wider layout it has room for. - - Keep this module free of imports: both entry points load it before anything - else has run. */ +/* Keep this module free of imports. Both entry points load it first. + Why the test has two halves: docs/frontend.md, Layout. */ export const MOBILE_QUERY = '(max-width:768px)'; const PORTRAIT_QUERY = '(orientation:portrait)'; diff --git a/public/js/mobile-metrics.js b/public/js/mobile-metrics.js index 0f0d5ee..70db323 100644 --- a/public/js/mobile-metrics.js +++ b/public/js/mobile-metrics.js @@ -1,10 +1,8 @@ -/* Every size must derive from sc. Raw px here, or CSS without var(--sc), breaks - rendering at one physical size whatever page scale the browser reports. +/* Every size must derive from sc. A raw px here breaks rendering at one + physical size whatever page scale the browser reports. - Safe-area insets are not here. The stylesheet holds them, and the layout - measures the box the stylesheet produced. The platform reports an inset after the - first paint, so a value read into JavaScript is stale from the moment it is - taken. */ + Never read a safe-area inset into JavaScript: it is reported after the first + paint, so the value is stale when it is taken. The stylesheet owns them. */ const BASE_VW = 393; diff --git a/public/js/modal.js b/public/js/modal.js index c5c0e81..7371999 100644 --- a/public/js/modal.js +++ b/public/js/modal.js @@ -1,12 +1,12 @@ // @ts-check -/* The one modal in the admin UI: backdrop, Escape, focus trap, and focus - returned to whatever opened it. Structure only. Callers fill the body. */ +/* Structure only; callers fill the body. */ -import { trapFocus } from '/js/dialog.js?v=05935547'; +/* A counter, not randomness. Six base-36 characters collide plausibly over a + long session, and an id that changes per run cannot be asserted on. */ +let _seq = 0; +const uniqueId = prefix => `${prefix}-${++_seq}`; /** Open a modal and return its parts. `close()` is safe to call more than once. - `focus()` arms the trap, because the element worth focusing is usually one - the caller has not appended yet. @param {{ title: string, className?: string, onClose?: () => void }} opts @returns {{ box: HTMLElement, body: HTMLElement, footer: HTMLElement, @@ -14,19 +14,14 @@ import { trapFocus } from '/js/dialog.js?v=05935547'; addAction: (label: string, cls: string, onAct?: () => void) => HTMLButtonElement, focus: (initial?: HTMLElement|null) => void }} */ export function openModal({ title, className, onClose }) { - const ov = document.createElement('div'); - ov.className = 'dlg-ov' + (className ? ' ' + className : ''); - - const box = document.createElement('div'); - box.className = 'dlg-box'; - box.setAttribute('role', 'dialog'); - box.setAttribute('aria-modal', 'true'); + const box = /** @type {HTMLDialogElement} */ (document.createElement('dialog')); + box.className = 'dlg-box' + (className ? ' ' + className : ''); const hdr = document.createElement('div'); hdr.className = 'dlg-hdr'; /* Unique per open. Two ids point the second dialog's label at the first one's heading. */ - hdr.id = 'dlg-hdr-' + Math.random().toString(36).slice(2, 8); + hdr.id = uniqueId('dlg-hdr'); hdr.textContent = title; box.setAttribute('aria-labelledby', hdr.id); @@ -36,24 +31,30 @@ export function openModal({ title, className, onClose }) { const footer = document.createElement('div'); footer.className = 'dlg-foot'; - let release = () => {}; let closed = false; const close = () => { if (closed) return; closed = true; - release(); - ov.remove(); - if (onClose) onClose(); + box.close(); }; - /* Both ends of the click. A text selection released past the dialog's edge - would otherwise dismiss it. */ + /* Escape and the close() above arrive here alike, so the caller is told once + however the dialog went away. */ + box.addEventListener('close', () => { + closed = true; + box.remove(); + if (onClose) onClose(); + }); + + /* A click on the backdrop is reported against the dialog itself, because the + backdrop is not an element. Both ends of the click are checked: a text + selection released past the dialog's edge would otherwise dismiss it. */ let downOnBackdrop = false; - ov.onmousedown = e => { - downOnBackdrop = e.target === ov; + box.onmousedown = e => { + downOnBackdrop = e.target === box; }; - ov.onclick = e => { - if (e.target === ov && downOnBackdrop) close(); + box.onclick = e => { + if (e.target === box && downOnBackdrop) close(); }; const addAction = (label, cls, onAct) => { @@ -70,14 +71,13 @@ export function openModal({ title, className, onClose }) { }; box.append(hdr, body, footer); - ov.appendChild(box); - document.body.appendChild(ov); + document.body.appendChild(box); + box.showModal(); + /* The element worth focusing is usually one the caller appends after opening, + so showModal's own choice is corrected here. */ const focus = initial => { - /* Arming twice is a caller re-rendering the body. The previous trap must - go, or its listener outlives the dialog. */ - release(); - release = trapFocus(box, { onClose: close, initialFocus: initial }); + if (initial) initial.focus(); }; return { box, body, footer, close, addAction, focus }; @@ -102,6 +102,18 @@ export function confirmModal({ title, body, confirmLabel, cancelLabel, destructi }); } +/** confirmModal for a plain sentence, which is what a replaced confirm() asks. + + @param {{ title: string, text: string, confirmLabel: string, cancelLabel: string, + destructive?: boolean }} opts + @returns {Promise} */ +export function confirmText({ title, text, confirmLabel, cancelLabel, destructive }) { + const lead = document.createElement('p'); + lead.className = 'dlg-lead'; + lead.textContent = text; + return confirmModal({ title, body: lead, confirmLabel, cancelLabel, destructive }); +} + /** A modal asking for one line of text, resolving to the trimmed value or null. @param {{ title: string, label: string, placeholder?: string, diff --git a/public/js/settings-icon.js b/public/js/settings-icon.js new file mode 100644 index 0000000..f49f776 --- /dev/null +++ b/public/js/settings-icon.js @@ -0,0 +1,6 @@ +// @ts-check +/* Inlined, not a file under /icons: that path is a user volume, and a file of + the same name left there would shadow the bundled artwork. */ + +export const SETTINGS_ICON = + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAyNHB0IiBoZWlnaHQ9IjEwMjRwdCIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsPSIjZjJmMmY3IiBzdHJva2U9IiNmMmYyZjciIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gMzU1LjU1IDM5OC43NyBDIDM2NC45OSAzOTcuNjIgMzc0LjUxIDM5OC4xMCAzODQuMDAgMzk4LjAwIEMgNDc0LjY3IDM5OC4wMCA1NjUuMzQgMzk4LjAxIDY1Ni4wMSAzOTcuOTkgQyA2NzcuMTEgMzk3Ljk1IDY5OC4yMiA0MDMuODMgNzE2LjE1IDQxNC45OSBDIDc0Ni42MiA0MzMuNjAgNzY3LjMwIDQ2Ny4zMiA3NjkuNjYgNTAzLjAwIEMgNzcyLjYwIDUzOC4xNiA3NTcuNzIgNTc0LjA5IDczMS4wMCA1OTcuMDcgQyA3MTQuMjIgNjExLjcyIDY5My4wNCA2MjEuMjcgNjcwLjk0IDYyNC4wNSBDIDY2MS42NyA2MjUuMzQgNjUyLjMwIDYyNC45NCA2NDIuOTggNjI1LjAwIEMgNTU3LjMxIDYyNS4wMCA0NzEuNjQgNjI1LjAwIDM4NS45NyA2MjUuMDAgQyAzNzMuMzAgNjI0LjkyIDM2MC41MCA2MjUuNjAgMzQ3Ljk5IDYyMy4xMCBDIDMxNy4yMiA2MTcuNjAgMjg5LjIzIDU5OC42MiAyNzIuNjEgNTcyLjE2IEMgMjU5Ljc3IDU1Mi4wMyAyNTMuNjAgNTI3Ljc1IDI1NS4yNiA1MDMuOTMgQyAyNTYuNzAgNDgxLjI4IDI2NS4yNCA0NTkuMTcgMjc5LjMyIDQ0MS4zNyBDIDI5Ny43MyA0MTcuNzkgMzI1LjgwIDQwMi4wNiAzNTUuNTUgMzk4Ljc3IE0gNDY5LjMxIDQxMC40NyBDIDQ1Mi43OSA0MTIuMjIgNDM2LjYwIDQxNy41OCA0MjIuNzEgNDI2Ljc4IEMgNDAwLjE2IDQ0MS40MCAzODQuMDUgNDY1LjUyIDM3OS4xMCA0OTEuOTMgQyAzNzQuNjQgNTExLjQwIDM3Ny42NCA1MzEuODcgMzg0Ljc1IDU1MC4zMiBDIDM4OC4wNiA1NTcuNDAgMzkxLjMzIDU2NC41OSAzOTYuMjYgNTcwLjczIEMgNDE0Ljg1IDU5Ny42MyA0NDcuNTcgNjEzLjMwIDQ4MC4wNSA2MTMuMDEgQyA1MzguNzIgNjEzLjAwIDU5Ny4zOCA2MTMuMDQgNjU2LjA1IDYxMi45NyBDIDY4NC44OSA2MTIuMjggNzEzLjU2IDU5OS4zNyA3MzIuMDAgNTc2Ljk3IEMgNzM2LjY3IDU3MC44MyA3NDEuNTkgNTY0Ljc4IDc0NC43NCA1NTcuNjggQyA3NTAuMTEgNTQ3LjkxIDc1Mi44NSA1MzYuOTYgNzU0Ljg0IDUyNi4wNyBDIDc1Ny45MiA1MDguMzMgNzU0Ljc2IDQ5MC4wNiA3NDguNTMgNDczLjM1IEMgNzQ1LjE5IDQ2NS43NSA3NDEuNDcgNDU4LjI2IDczNi4zMiA0NTEuNzEgQyA3MjQuOTYgNDM1LjQxIDcwOC4xMyA0MjMuMzYgNjg5LjY5IDQxNi4zMSBDIDY3OC4xNiA0MTIuNTkgNjY2LjEzIDQwOS44NyA2NTMuOTYgNDEwLjAzIEMgNTk3LjI5IDQwOS45NSA1NDAuNjEgNDEwLjAzIDQ4My45NCA0MTAuMDAgQyA0NzkuMDYgNDA5Ljk4IDQ3NC4xNyA0MDkuOTQgNDY5LjMxIDQxMC40NyBaIiAvPgo8cGF0aCBmaWxsPSIjZGZkZmU0IiBzdHJva2U9IiNkZmRmZTQiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY5LjMxIDQxMC40NyBDIDQ3NC4xNyA0MDkuOTQgNDc5LjA2IDQwOS45OCA0ODMuOTQgNDEwLjAwIEMgNTQwLjYxIDQxMC4wMyA1OTcuMjkgNDA5Ljk1IDY1My45NiA0MTAuMDMgQyA2NjYuMTMgNDA5Ljg3IDY3OC4xNiA0MTIuNTkgNjg5LjY5IDQxNi4zMSBDIDcwOC4xMyA0MjMuMzYgNzI0Ljk2IDQzNS40MSA3MzYuMzIgNDUxLjcxIEMgNzQxLjQ3IDQ1OC4yNiA3NDUuMTkgNDY1Ljc1IDc0OC41MyA0NzMuMzUgQyA3NTQuNzYgNDkwLjA2IDc1Ny45MiA1MDguMzMgNzU0Ljg0IDUyNi4wNyBDIDc1Mi44NSA1MzYuOTYgNzUwLjExIDU0Ny45MSA3NDQuNzQgNTU3LjY4IEMgNzQxLjU5IDU2NC43OCA3MzYuNjcgNTcwLjgzIDczMi4wMCA1NzYuOTcgQyA3MTMuNTYgNTk5LjM3IDY4NC44OSA2MTIuMjggNjU2LjA1IDYxMi45NyBDIDU5Ny4zOCA2MTMuMDQgNTM4LjcyIDYxMy4wMCA0ODAuMDUgNjEzLjAxIEMgNDQ3LjU3IDYxMy4zMCA0MTQuODUgNTk3LjYzIDM5Ni4yNiA1NzAuNzMgQyAzOTEuMzMgNTY0LjU5IDM4OC4wNiA1NTcuNDAgMzg0Ljc1IDU1MC4zMiBDIDM3Ny42NCA1MzEuODcgMzc0LjY0IDUxMS40MCAzNzkuMTAgNDkxLjkzIEMgMzg0LjA1IDQ2NS41MiA0MDAuMTYgNDQxLjQwIDQyMi43MSA0MjYuNzggQyA0MzYuNjAgNDE3LjU4IDQ1Mi43OSA0MTIuMjIgNDY5LjMxIDQxMC40NyBNIDQ2Ny40OCA0MjIuNjYgQyA0MzYuNjggNDI2LjEzIDQwOC43NCA0NDcuMDggMzk2LjUwIDQ3NS41MSBDIDM4Ny43NyA0OTUuMjcgMzg2LjU4IDUxOC4yMSAzOTMuMjEgNTM4Ljc2IEMgMzk4LjcyIDU1Ni4xNiA0MDkuNzUgNTcxLjc1IDQyNC4zMCA1ODIuNzYgQyA0MzkuMDQgNTk0LjA3IDQ1Ny4zOSA2MDAuNTEgNDc1Ljk1IDYwMS4wMCBDIDUzMi4zMiA2MDEuMDAgNTg4LjY5IDYwMS4wMCA2NDUuMDYgNjAxLjAwIEMgNjUyLjE5IDYwMC45NCA2NTkuMzcgNjAxLjMzIDY2Ni40NiA2MDAuMjIgQyA2ODMuMTYgNTk4LjA4IDY5OS4xNCA1OTAuOTUgNzEyLjAyIDU4MC4xMSBDIDcyOC41OCA1NjYuMzIgNzM5Ljg5IDU0Ni4zMiA3NDMuMDAgNTI0Ljk4IEMgNzQ1Ljg2IDUwNi42NSA3NDIuNzYgNDg3LjQ2IDczNC4zNCA0NzAuOTQgQyA3MjYuNzQgNDU1Ljk3IDcxNC44NCA0NDMuMjMgNzAwLjQxIDQzNC42MyBDIDY4NS45NCA0MjUuODggNjY4LjkyIDQyMS41NCA2NTIuMDMgNDIxLjk5IEMgNTk1LjY2IDQyMi4wMSA1MzkuMjkgNDIyLjAwIDQ4Mi45MSA0MjIuMDAgQyA0NzcuNzYgNDIxLjk0IDQ3Mi41OSA0MjEuOTQgNDY3LjQ4IDQyMi42NiBaIiAvPgo8cGF0aCBmaWxsPSIjMzg3Zjk1IiBzdHJva2U9IiMzODdmOTUiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY3LjQ4IDQyMi42NiBDIDQ3Mi41OSA0MjEuOTQgNDc3Ljc2IDQyMS45NCA0ODIuOTEgNDIyLjAwIEMgNTM5LjI5IDQyMi4wMCA1OTUuNjYgNDIyLjAxIDY1Mi4wMyA0MjEuOTkgQyA2NjguOTIgNDIxLjU0IDY4NS45NCA0MjUuODggNzAwLjQxIDQzNC42MyBDIDcxNC44NCA0NDMuMjMgNzI2Ljc0IDQ1NS45NyA3MzQuMzQgNDcwLjk0IEMgNzQyLjc2IDQ4Ny40NiA3NDUuODYgNTA2LjY1IDc0My4wMCA1MjQuOTggQyA3MzkuODkgNTQ2LjMyIDcyOC41OCA1NjYuMzIgNzEyLjAyIDU4MC4xMSBDIDY5OS4xNCA1OTAuOTUgNjgzLjE2IDU5OC4wOCA2NjYuNDYgNjAwLjIyIEMgNjU5LjM3IDYwMS4zMyA2NTIuMTkgNjAwLjk0IDY0NS4wNiA2MDEuMDAgQyA1ODguNjkgNjAxLjAwIDUzMi4zMiA2MDEuMDAgNDc1Ljk1IDYwMS4wMCBDIDQ1Ny4zOSA2MDAuNTEgNDM5LjA0IDU5NC4wNyA0MjQuMzAgNTgyLjc2IEMgNDA5Ljc1IDU3MS43NSAzOTguNzIgNTU2LjE2IDM5My4yMSA1MzguNzYgQyAzODYuNTggNTE4LjIxIDM4Ny43NyA0OTUuMjcgMzk2LjUwIDQ3NS41MSBDIDQwOC43NCA0NDcuMDggNDM2LjY4IDQyNi4xMyA0NjcuNDggNDIyLjY2IFoiIC8+Cjwvc3ZnPg=='; diff --git a/public/js/spotlight.js b/public/js/spotlight.js index d66a600..4e7dc18 100644 --- a/public/js/spotlight.js +++ b/public/js/spotlight.js @@ -1,6 +1,5 @@ -import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=8ca7ce3c'; -import { wrapTab } from '/js/dialog.js?v=05935547'; -import { t } from '/js/i18n.js?v=83239bf4'; +import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=d949e985'; +import { t } from '/js/i18n.js?v=e644a5c5'; /* Attached to the window so a re-open can undo the previous one. */ const _w = /** @type {any} */ (window); @@ -9,14 +8,13 @@ const _w = /** @type {any} */ (window); this module is loaded. */ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDesktop, openFolderMobile }) { const MOB = () => isMob(); - const ov = el('spot'); + const ov = /** @type {HTMLDialogElement} */ (el('spot')); const inp = inpById('sin'); const res = el('sres'); const cancelBtn = el('spot-cancel'); const live = el('sres-live'); let si = 0, cur = []; - let lastFocused = null; inp.setAttribute('role', 'combobox'); inp.setAttribute('aria-autocomplete', 'list'); @@ -26,7 +24,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto res.setAttribute('aria-label', t('home.searchResults')); const render = q => { - res.innerHTML = ''; + res.replaceChildren(); const apps = getItems().filter(i => i.type === 'app' || i.type === 'folder'); const qq = q.toLowerCase().trim(); cur = qq ? apps.filter(a => (a.label || a.id).toLowerCase().includes(qq)) : apps; @@ -99,7 +97,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto step++; if (step < srcs.length) img.src = srcs[step]; else { - ic.innerHTML = ''; + ic.replaceChildren(); ic.textContent = (app.label || app.id)[0].toUpperCase(); ic.style.color = '#fff'; ic.style.fontWeight = '600'; @@ -148,15 +146,9 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto } }); - const barEl = q('.spot-bar', ov); - if (MOB() && barEl) { - barEl.style.cssText = - 'display:flex;align-items:center;gap:12px;padding:16px 18px;border-radius:18px;border:none;background:rgba(118,118,128,.30);box-sizing:border-box;'; - inp.style.cssText = - 'flex:1;background:transparent;border:0;outline:none;font-size:17px;color:rgba(255,255,255,.92);font-family:inherit;caret-color:#007aff;-webkit-appearance:none;min-height:26px;padding:0;margin:0;'; - } - if (barEl) - barEl.addEventListener( + const fieldEl = q('.spot-field', ov); + if (fieldEl) + fieldEl.addEventListener( 'touchend', e => { e.preventDefault(); @@ -166,20 +158,23 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto { passive: false }, ); + /* Set both edges. iOS scrolls a smaller visual viewport over an unchanged + page, so the height alone leaves the overlay off the screen. */ function _applyKbLayout() { if (!MOB()) return; - const vvH = window.visualViewport ? window.visualViewport.height : window.innerHeight; - ov.style.bottom = Math.max(0, window.innerHeight - vvH) + 'px'; + const vv = window.visualViewport; + if (!vv) return; + const top = Math.max(0, vv.offsetTop); + const bottom = Math.max(0, window.innerHeight - vv.height - top); + ov.style.top = top + 'px'; + ov.style.bottom = bottom + 'px'; + ov.classList.toggle('kb', bottom > 120); } - const trap = e => { - wrapTab(e, ov); - }; function open(ch) { - lastFocused = document.activeElement; + if (ov.open) return; + ov.showModal(); ov.classList.add('on'); - ov.setAttribute('aria-hidden', 'false'); - ov.addEventListener('keydown', trap); inp.value = ch || ''; render(inp.value); if (MOB() && window.visualViewport) { @@ -206,28 +201,36 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto ); } + /* Escape would close the dialog outright and skip the fade, so it is refused + and routed through close() like every other dismissal. */ + ov.addEventListener('cancel', e => { + e.preventDefault(); + close(); + }); + + /* The dialog stays open for the length of the fade, so the page behind it + is inert until the overlay has actually gone. The browser returns focus + when it closes. */ + ov.addEventListener('close', () => { + ov.classList.remove('on'); + inp.value = ''; + res.replaceChildren(); + }); + function close() { + if (!ov.open) return; ov.classList.remove('vis'); - ov.removeEventListener('keydown', trap); - ov.setAttribute('aria-hidden', 'true'); inp.setAttribute('aria-expanded', 'false'); inp.setAttribute('aria-activedescendant', ''); - if (lastFocused && lastFocused.focus) { - try { - lastFocused.focus(); - } catch {} - lastFocused = null; - } if (_w._spotVpCleanup) { _w._spotVpCleanup(); _w._spotVpCleanup = null; } + ov.style.top = ''; ov.style.bottom = ''; + ov.classList.remove('kb'); setTimeout(() => { - if (!ov.classList.contains('vis')) ov.classList.remove('on'); - inp.value = ''; - res.innerHTML = ''; - inp.blur(); + if (!ov.classList.contains('vis')) ov.close(); }, 220); } @@ -251,18 +254,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto 'touchend', e => { e.preventDefault(); - close(); - }, - { passive: false }, - ); - } - const mobCancelBtn = el('spot-cancel-mob-btn'); - if (mobCancelBtn) { - mobCancelBtn.onclick = close; - mobCancelBtn.addEventListener( - 'touchend', - e => { - e.preventDefault(); + e.stopPropagation(); close(); }, { passive: false }, diff --git a/public/js/theme.js b/public/js/theme.js index 6d72aef..8e00bf5 100644 --- a/public/js/theme.js +++ b/public/js/theme.js @@ -1,15 +1,6 @@ -/* The Settings page appearance: dark, light, or whichever the device asks for. - - The choice is per device, not per dashboard. It is read from localStorage and - never written to the config, so two people opening Settings on two machines - each get their own, and a machine keeps its own through a config import. - - "system" is resolved here rather than in a media query. The stylesheets carry - one light block, selected by data-theme, and a prefers-color-scheme copy of it - would be a second place to change every colour. - - ui/js/admin-theme.js repeats what this file decides so the attribute is on - before the first paint. A parity test holds the two together. */ +/* Never write the choice to the config: it is per device. + Why it is resolved here rather than in a media query, and why admin-theme.js + repeats it: docs/frontend.md, Theme. */ export const THEME_KEY = 'sy-theme'; diff --git a/public/js/ui.js b/public/js/ui.js index 16bf7c2..ea5cf67 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,5 +1,5 @@ import { iconChain } from '/js/icons.js?v=69c2b9bd'; -import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=6a5e1619'; +import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=a1b61636'; import { mk, clr, @@ -11,11 +11,10 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=8ca7ce3c'; -import { t, currentLang } from '/js/i18n.js?v=83239bf4'; -import { trapFocus } from '/js/dialog.js?v=05935547'; -import { toneForColor } from '/js/label-contrast.js?v=38adb276'; -import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=424d5d41'; +} from '/js/utils.js?v=d949e985'; +import { t, currentLang } from '/js/i18n.js?v=e644a5c5'; +import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; +import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=7be08bb0'; let _state = null; export function initUI(state) { @@ -34,6 +33,8 @@ const st = () => _state; const widgetReg = () => _state?.widgetReg || Object.create(null); const mkWrap = (item, sz, r, isz, cls) => _mkWrap(item, sz, r, isz, cls, breg); +const folderName = f => (f.label ? t('type.folderNamed', { name: f.label }) : t('type.folder')); + /* A tap outside a widget lands in the parent document. Widgets expose window.__clearActive. */ function clearMobWidgets(exceptWin) { @@ -101,16 +102,13 @@ function mkMiniIcon(child, pointerEvents) { export function mkFolder(item) { const showLabel = S().showLabels?.desktop !== false; const iw = showLabel ? 72 : 78; - const a = mk('a'); + const a = mk('button'); + a.type = 'button'; a.className = 'icon'; - a.style.cursor = 'pointer'; - a.href = '#'; - a.setAttribute('role', 'button'); - a.setAttribute('aria-label', (item.label || t('type.folder')) + ' folder'); - a.dataset.tileName = (item.label || t('type.folder')) + ' folder'; + a.setAttribute('aria-label', folderName(item)); + a.dataset.tileName = folderName(item); if (!showLabel) a.title = item.label || t('type.folder'); - a.onclick = e => { - e.preventDefault(); + a.onclick = () => { openFolderDesktop(item); }; const box = mk('div'); @@ -157,14 +155,10 @@ export function openFolderDesktop(folder) { } const children = (folder.children || []).map(id => items().find(i => i.id === id)).filter(Boolean); const showLabel = S().showLabels?.desktop !== false; - const ov = mk('div'); + const ov = /** @type {HTMLDialogElement} */ (mk('dialog')); ov.className = 'folder-overlay'; - ov.setAttribute('role', 'dialog'); - ov.setAttribute('aria-modal', 'true'); - ov.setAttribute('aria-label', (folder.label || t('type.folder')) + ' folder'); + ov.setAttribute('aria-label', folderName(folder)); ov.tabIndex = -1; - const _prevFocus = /** @type {HTMLElement} */ (document.activeElement); - let releaseDeskTrap = null; const outer = mk('div'); outer.className = 'folder-outer'; const title = mk('div'); @@ -202,33 +196,28 @@ export function openFolderDesktop(folder) { children.forEach(c => bupd(c.id)); qa('.badge', grid).forEach(el => registeredBadges.push(el)); function closeDesk() { + ov.close(); + } + /* Escape and the tile's own toggle arrive here alike, and a badge registered + to an element that has gone keeps the dashboard repainting it. */ + ov.addEventListener('close', () => { registeredBadges.forEach(el => BEL().forEach((_, id) => bunreg(id, el))); - document.removeEventListener('keydown', escDesk); - if (releaseDeskTrap) { - releaseDeskTrap(); - releaseDeskTrap = null; - } ov.remove(); folderOverlay = null; - if (_prevFocus && _prevFocus.focus) _prevFocus.focus(); - } + }); + /* The overlay is the scrim, so a click reported against it is a click + outside the folder. */ ov.onclick = e => { if (e.target === ov) closeDesk(); }; - const escDesk = e => { - if (e.key === 'Escape') { - closeDesk(); - document.removeEventListener('keydown', escDesk); - } - }; - document.addEventListener('keydown', escDesk); outer.append(title, box); ov.appendChild(outer); document.body.appendChild(ov); folderOverlay = ov; - /* The dashboard behind stays focusable while covered, so the overlay needs a - Tab trap. Attach it after the overlay is in the document. */ - releaseDeskTrap = trapFocus(ov, { closeOnEscape: false, onClose: closeDesk, initialFocus: ov }); + /* Not show(): only showModal makes the dashboard behind it inert, and the + tiles under the scrim were reachable by Tab and by a screen reader. */ + ov.showModal(); + ov.focus(); } function mFolder(item, cw, rh, isz, ir, im, sc) { @@ -237,8 +226,8 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { const a = document.createElement('button'); a.type = 'button'; a.className = 'dyn-mob-btn'; - a.setAttribute('aria-label', (item.label || t('type.folder')) + ' folder'); - a.dataset.tileName = (item.label || t('type.folder')) + ' folder'; + a.setAttribute('aria-label', folderName(item)); + a.dataset.tileName = folderName(item); css(a, { '--rh': rh + 'px' }); let _opening = false; function _openFolder() { @@ -340,23 +329,21 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { let curPage = 0; const vw = innerWidth, vh = innerHeight; - const ov = mk('div'); + const ov = /** @type {HTMLDialogElement} */ (mk('dialog')); ov.className = 'folder-overlay-mobile'; - ov.setAttribute('role', 'dialog'); - ov.setAttribute('aria-modal', 'true'); - ov.setAttribute('aria-label', (folder.label || t('type.folder')) + ' folder'); + ov.setAttribute('aria-label', folderName(folder)); ov.tabIndex = -1; - let releaseMobTrap = null; function closeMob() { + ov.close(); + } + /* A badge registered to an element that has gone keeps the dashboard + repainting it. */ + ov.addEventListener('close', () => { qa('.badge', ov).forEach(el => BEL().forEach((_, id) => bunreg(id, el))); - if (releaseMobTrap) { - releaseMobTrap(); - releaseMobTrap = null; - } ov.remove(); folderOverlayMob = null; - } + }); const ptScale = vw / 393; const margin = Math.round(34 * ptScale), @@ -546,7 +533,8 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { ); document.body.appendChild(ov); folderOverlayMob = ov; - releaseMobTrap = trapFocus(ov, { onClose: closeMob, initialFocus: ov }); + ov.showModal(); + ov.focus(); } /* Cells per widget size on the 4x6 home grid. */ @@ -574,7 +562,7 @@ export function buildMobile() { xlarge 4×6. A footprint is a physical size, so it does not change with the column count: a wider box gets more columns, not larger widgets. */ const strip = el('pages'); - strip.innerHTML = ''; + strip.replaceChildren(); function mkPage() { const p = mk('div'); @@ -758,7 +746,7 @@ export function buildMobile() { const dw = el('dots'); dw.style.cssText = 'display:none'; - dw.innerHTML = ''; + dw.replaceChildren(); const dk = el('dock'); dk.className = 'mdock'; @@ -775,8 +763,8 @@ export function buildMobile() { ? dock.length * dockIconSz + (dock.length - 1) * Math.round(22 * sc) + dockPad * 2 : maxDockW; const dockW = Math.min(maxDockW, dockContentW); - dk.style.cssText = `position:fixed;left:50%;bottom:${dockGap}px;-webkit-transform:translateX(-50%);transform:translateX(-50%);width:${dockW}px;height:${dh}px;padding:0 ${dockPad}px;border-radius:${Math.round(44 * sc)}px;z-index:400;`; - dk.innerHTML = ''; + dk.style.cssText = `position:fixed;left:50%;bottom:${dockGap}px;transform:translateX(-50%);width:${dockW}px;height:${dh}px;padding:0 ${dockPad}px;border-radius:${Math.round(44 * sc)}px;z-index:400;`; + dk.replaceChildren(); dock.forEach(item => { const a = mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' }); a.className = 'dyn-dock-icon'; @@ -794,11 +782,11 @@ export function buildMobile() { _pdotPad = Math.round(14 * sc); const pillDotsW = pages.length * (_pdotSz + _pdotGap) - _pdotGap + _pdotPad * 2; const pill = el('mob-search-pill'); - pill.style.cssText = `position:fixed;left:50%;bottom:${dockGap + dh + pillGap}px;-webkit-transform:translateX(-50%);transform:translateX(-50%);width:${pillSearchW}px;height:${pillH}px;display:-webkit-flex;display:flex;z-index:500;`; + pill.style.cssText = `position:fixed;left:50%;bottom:${dockGap + dh + pillGap}px;transform:translateX(-50%);width:${pillSearchW}px;height:${pillH}px;display:flex;z-index:500;`; const pillNew = /** @type {HTMLElement} */ (pill.cloneNode(true)); const pillNewDots = q('.msp-dots', pillNew); - pillNewDots.innerHTML = ''; + pillNewDots.replaceChildren(); const pillDotEls = pages.map((_, i) => { const d = document.createElement('div'); d.className = 'msp-dot' + (i === 0 ? ' on' : ''); diff --git a/public/js/utils.js b/public/js/utils.js index dc1c2e7..0966259 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,5 +1,6 @@ import { iconChain } from '/js/icons.js?v=69c2b9bd'; -import { toneForColor } from '/js/label-contrast.js?v=38adb276'; +import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; +import { SETTINGS_ICON } from '/js/settings-icon.js?v=b96e5b13'; export const mk = (t, a = {}) => { const e = document.createElement(t); @@ -10,7 +11,7 @@ export const mk = (t, a = {}) => { arrive in an imported config. It is assigned to a background, where a CSS url() fetches from whatever host it names. */ const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([0-9a-z%.,\s/+-]*\)|[a-z]{3,20})$/i; -export const DEFAULT_TILE_COLOR = '#1C1C1E'; +const DEFAULT_TILE_COLOR = '#1C1C1E'; export const clr = c => { if (!c || c === 'dark') return DEFAULT_TILE_COLOR; if (c === 'light') return '#F2F2F7'; @@ -106,14 +107,11 @@ export const qa = (sel, root = document) => /** @type {HTMLElement[]} */ ([...ro export const tgt = e => /** @type {HTMLInputElement} */ (e.target); /* breg is passed in to avoid a circular import. */ -const SETTINGS_ICON = - 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAyNHB0IiBoZWlnaHQ9IjEwMjRwdCIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsPSIjZjJmMmY3IiBzdHJva2U9IiNmMmYyZjciIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gMzU1LjU1IDM5OC43NyBDIDM2NC45OSAzOTcuNjIgMzc0LjUxIDM5OC4xMCAzODQuMDAgMzk4LjAwIEMgNDc0LjY3IDM5OC4wMCA1NjUuMzQgMzk4LjAxIDY1Ni4wMSAzOTcuOTkgQyA2NzcuMTEgMzk3Ljk1IDY5OC4yMiA0MDMuODMgNzE2LjE1IDQxNC45OSBDIDc0Ni42MiA0MzMuNjAgNzY3LjMwIDQ2Ny4zMiA3NjkuNjYgNTAzLjAwIEMgNzcyLjYwIDUzOC4xNiA3NTcuNzIgNTc0LjA5IDczMS4wMCA1OTcuMDcgQyA3MTQuMjIgNjExLjcyIDY5My4wNCA2MjEuMjcgNjcwLjk0IDYyNC4wNSBDIDY2MS42NyA2MjUuMzQgNjUyLjMwIDYyNC45NCA2NDIuOTggNjI1LjAwIEMgNTU3LjMxIDYyNS4wMCA0NzEuNjQgNjI1LjAwIDM4NS45NyA2MjUuMDAgQyAzNzMuMzAgNjI0LjkyIDM2MC41MCA2MjUuNjAgMzQ3Ljk5IDYyMy4xMCBDIDMxNy4yMiA2MTcuNjAgMjg5LjIzIDU5OC42MiAyNzIuNjEgNTcyLjE2IEMgMjU5Ljc3IDU1Mi4wMyAyNTMuNjAgNTI3Ljc1IDI1NS4yNiA1MDMuOTMgQyAyNTYuNzAgNDgxLjI4IDI2NS4yNCA0NTkuMTcgMjc5LjMyIDQ0MS4zNyBDIDI5Ny43MyA0MTcuNzkgMzI1LjgwIDQwMi4wNiAzNTUuNTUgMzk4Ljc3IE0gNDY5LjMxIDQxMC40NyBDIDQ1Mi43OSA0MTIuMjIgNDM2LjYwIDQxNy41OCA0MjIuNzEgNDI2Ljc4IEMgNDAwLjE2IDQ0MS40MCAzODQuMDUgNDY1LjUyIDM3OS4xMCA0OTEuOTMgQyAzNzQuNjQgNTExLjQwIDM3Ny42NCA1MzEuODcgMzg0Ljc1IDU1MC4zMiBDIDM4OC4wNiA1NTcuNDAgMzkxLjMzIDU2NC41OSAzOTYuMjYgNTcwLjczIEMgNDE0Ljg1IDU5Ny42MyA0NDcuNTcgNjEzLjMwIDQ4MC4wNSA2MTMuMDEgQyA1MzguNzIgNjEzLjAwIDU5Ny4zOCA2MTMuMDQgNjU2LjA1IDYxMi45NyBDIDY4NC44OSA2MTIuMjggNzEzLjU2IDU5OS4zNyA3MzIuMDAgNTc2Ljk3IEMgNzM2LjY3IDU3MC44MyA3NDEuNTkgNTY0Ljc4IDc0NC43NCA1NTcuNjggQyA3NTAuMTEgNTQ3LjkxIDc1Mi44NSA1MzYuOTYgNzU0Ljg0IDUyNi4wNyBDIDc1Ny45MiA1MDguMzMgNzU0Ljc2IDQ5MC4wNiA3NDguNTMgNDczLjM1IEMgNzQ1LjE5IDQ2NS43NSA3NDEuNDcgNDU4LjI2IDczNi4zMiA0NTEuNzEgQyA3MjQuOTYgNDM1LjQxIDcwOC4xMyA0MjMuMzYgNjg5LjY5IDQxNi4zMSBDIDY3OC4xNiA0MTIuNTkgNjY2LjEzIDQwOS44NyA2NTMuOTYgNDEwLjAzIEMgNTk3LjI5IDQwOS45NSA1NDAuNjEgNDEwLjAzIDQ4My45NCA0MTAuMDAgQyA0NzkuMDYgNDA5Ljk4IDQ3NC4xNyA0MDkuOTQgNDY5LjMxIDQxMC40NyBaIiAvPgo8cGF0aCBmaWxsPSIjZGZkZmU0IiBzdHJva2U9IiNkZmRmZTQiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY5LjMxIDQxMC40NyBDIDQ3NC4xNyA0MDkuOTQgNDc5LjA2IDQwOS45OCA0ODMuOTQgNDEwLjAwIEMgNTQwLjYxIDQxMC4wMyA1OTcuMjkgNDA5Ljk1IDY1My45NiA0MTAuMDMgQyA2NjYuMTMgNDA5Ljg3IDY3OC4xNiA0MTIuNTkgNjg5LjY5IDQxNi4zMSBDIDcwOC4xMyA0MjMuMzYgNzI0Ljk2IDQzNS40MSA3MzYuMzIgNDUxLjcxIEMgNzQxLjQ3IDQ1OC4yNiA3NDUuMTkgNDY1Ljc1IDc0OC41MyA0NzMuMzUgQyA3NTQuNzYgNDkwLjA2IDc1Ny45MiA1MDguMzMgNzU0Ljg0IDUyNi4wNyBDIDc1Mi44NSA1MzYuOTYgNzUwLjExIDU0Ny45MSA3NDQuNzQgNTU3LjY4IEMgNzQxLjU5IDU2NC43OCA3MzYuNjcgNTcwLjgzIDczMi4wMCA1NzYuOTcgQyA3MTMuNTYgNTk5LjM3IDY4NC44OSA2MTIuMjggNjU2LjA1IDYxMi45NyBDIDU5Ny4zOCA2MTMuMDQgNTM4LjcyIDYxMy4wMCA0ODAuMDUgNjEzLjAxIEMgNDQ3LjU3IDYxMy4zMCA0MTQuODUgNTk3LjYzIDM5Ni4yNiA1NzAuNzMgQyAzOTEuMzMgNTY0LjU5IDM4OC4wNiA1NTcuNDAgMzg0Ljc1IDU1MC4zMiBDIDM3Ny42NCA1MzEuODcgMzc0LjY0IDUxMS40MCAzNzkuMTAgNDkxLjkzIEMgMzg0LjA1IDQ2NS41MiA0MDAuMTYgNDQxLjQwIDQyMi43MSA0MjYuNzggQyA0MzYuNjAgNDE3LjU4IDQ1Mi43OSA0MTIuMjIgNDY5LjMxIDQxMC40NyBNIDQ2Ny40OCA0MjIuNjYgQyA0MzYuNjggNDI2LjEzIDQwOC43NCA0NDcuMDggMzk2LjUwIDQ3NS41MSBDIDM4Ny43NyA0OTUuMjcgMzg2LjU4IDUxOC4yMSAzOTMuMjEgNTM4Ljc2IEMgMzk4LjcyIDU1Ni4xNiA0MDkuNzUgNTcxLjc1IDQyNC4zMCA1ODIuNzYgQyA0MzkuMDQgNTk0LjA3IDQ1Ny4zOSA2MDAuNTEgNDc1Ljk1IDYwMS4wMCBDIDUzMi4zMiA2MDEuMDAgNTg4LjY5IDYwMS4wMCA2NDUuMDYgNjAxLjAwIEMgNjUyLjE5IDYwMC45NCA2NTkuMzcgNjAxLjMzIDY2Ni40NiA2MDAuMjIgQyA2ODMuMTYgNTk4LjA4IDY5OS4xNCA1OTAuOTUgNzEyLjAyIDU4MC4xMSBDIDcyOC41OCA1NjYuMzIgNzM5Ljg5IDU0Ni4zMiA3NDMuMDAgNTI0Ljk4IEMgNzQ1Ljg2IDUwNi42NSA3NDIuNzYgNDg3LjQ2IDczNC4zNCA0NzAuOTQgQyA3MjYuNzQgNDU1Ljk3IDcxNC44NCA0NDMuMjMgNzAwLjQxIDQzNC42MyBDIDY4NS45NCA0MjUuODggNjY4LjkyIDQyMS41NCA2NTIuMDMgNDIxLjk5IEMgNTk1LjY2IDQyMi4wMSA1MzkuMjkgNDIyLjAwIDQ4Mi45MSA0MjIuMDAgQyA0NzcuNzYgNDIxLjk0IDQ3Mi41OSA0MjEuOTQgNDY3LjQ4IDQyMi42NiBaIiAvPgo8cGF0aCBmaWxsPSIjMzg3Zjk1IiBzdHJva2U9IiMzODdmOTUiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY3LjQ4IDQyMi42NiBDIDQ3Mi41OSA0MjEuOTQgNDc3Ljc2IDQyMS45NCA0ODIuOTEgNDIyLjAwIEMgNTM5LjI5IDQyMi4wMCA1OTUuNjYgNDIyLjAxIDY1Mi4wMyA0MjEuOTkgQyA2NjguOTIgNDIxLjU0IDY4NS45NCA0MjUuODggNzAwLjQxIDQzNC42MyBDIDcxNC44NCA0NDMuMjMgNzI2Ljc0IDQ1NS45NyA3MzQuMzQgNDcwLjk0IEMgNzQyLjc2IDQ4Ny40NiA3NDUuODYgNTA2LjY1IDc0My4wMCA1MjQuOTggQyA3MzkuODkgNTQ2LjMyIDcyOC41OCA1NjYuMzIgNzEyLjAyIDU4MC4xMSBDIDY5OS4xNCA1OTAuOTUgNjgzLjE2IDU5OC4wOCA2NjYuNDYgNjAwLjIyIEMgNjU5LjM3IDYwMS4zMyA2NTIuMTkgNjAwLjk0IDY0NS4wNiA2MDEuMDAgQyA1ODguNjkgNjAxLjAwIDUzMi4zMiA2MDEuMDAgNDc1Ljk1IDYwMS4wMCBDIDQ1Ny4zOSA2MDAuNTEgNDM5LjA0IDU5NC4wNyA0MjQuMzAgNTgyLjc2IEMgNDA5Ljc1IDU3MS43NSAzOTguNzIgNTU2LjE2IDM5My4yMSA1MzguNzYgQyAzODYuNTggNTE4LjIxIDM4Ny43NyA0OTUuMjcgMzk2LjUwIDQ3NS41MSBDIDQwOC43NCA0NDcuMDggNDM2LjY4IDQyNi4xMyA0NjcuNDggNDIyLjY2IFoiIC8+Cjwvc3ZnPg=='; - export function mkWrap(item, sz, r, isz, cls, breg) { const w = mk('div'); if (cls) w.className = cls; const wrapBg = item.system === 'settings' ? '#027eae' : clr(item.color); - w.style.cssText = `width:${sz}px;height:${sz}px;border-radius:${r}px;background:${wrapBg};position:relative;flex-shrink:0;overflow:visible;display:-webkit-flex;display:flex;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;box-shadow:inset 1px 1px 0 rgba(255,255,255,.18),inset -1px -1px 0 rgba(0,0,0,.14);`; + w.style.cssText = `width:${sz}px;height:${sz}px;border-radius:${r}px;background:${wrapBg};position:relative;flex-shrink:0;overflow:visible;display:flex;align-items:center;justify-content:center;box-shadow:inset 1px 1px 0 rgba(255,255,255,.18),inset -1px -1px 0 rgba(0,0,0,.14);`; const g = mk('div'); g.style.cssText = `position:absolute;inset:0;border-radius:${r}px;pointer-events:none;z-index:2;background:linear-gradient(135deg,rgba(255,255,255,.10) 0%,transparent 60%);`; w.appendChild(g); @@ -202,6 +200,23 @@ export function safeAllow(value) { /* Mounts the iframe at a fixed design resolution and scales it to fill `card`. `card` must be aspect-locked to the design ratio. */ +/* Withheld: allow-top-navigation. Without it a framed page cannot redirect the + dashboard through top.location. Everything a real service needs is granted, + including its own origin, so its session and storage still work. + + Only a cross-origin frame gets this. A bundled widget is served from here, so + it would need allow-same-origin and the attribute would withhold nothing. */ +const SANDBOX = + 'allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-downloads'; + +const isCrossOrigin = src => { + try { + return new URL(src, location.href).origin !== location.origin; + } catch { + return false; + } +}; + /** @param {HTMLElement} card @param {{ src?: string, title?: string, design?: [number, number], @@ -221,6 +236,7 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla const clip = mk('div'); clip.style.cssText = 'position:absolute;inset:0;overflow:hidden;'; const ifr = mk('iframe', { src, scrolling: o.scrolling === true || o.scrolling === 'yes' ? 'yes' : 'no', title }); + if (isCrossOrigin(src)) ifr.setAttribute('sandbox', SANDBOX); ifr.setAttribute('allow', safeAllow(o.allow)); if (o.allowFullscreen !== false) ifr.setAttribute('allowfullscreen', ''); if (o.referrerPolicy) ifr.setAttribute('referrerpolicy', o.referrerPolicy); diff --git a/public/js/wallpaper-cache.js b/public/js/wallpaper-cache.js new file mode 100644 index 0000000..87d1b35 --- /dev/null +++ b/public/js/wallpaper-cache.js @@ -0,0 +1,58 @@ +// @ts-check +/* The wallpaper both first-party pages show. Reads and writes one entry so the + dashboard and the settings pages never show different photos. */ + +export const WALLPAPER_STORE = 'dash_wallpaper'; + +/** One day. */ +const WALLPAPER_TTL_MS = 24 * 60 * 60 * 1000; + +/** @param {any} bg the background settings @returns {string} */ +function wallpaperKey(bg) { + return `${bg?.type || ''}|${bg?.collection || ''}`; +} + +/** The still-valid wallpaper URL held for these background settings, if any. + + @param {string|null|undefined} stored the raw stored entry + @param {any} bg the background settings the page was built from + @param {number} now + @returns {string|null} */ +export function readWallpaperCache(stored, bg, now) { + if (typeof stored !== 'string' || !stored) return null; + let entry; + try { + entry = JSON.parse(stored); + } catch { + return null; + } + if (!entry || typeof entry.url !== 'string' || !entry.url) return null; + if (entry.key !== wallpaperKey(bg)) return null; + if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null; + /* A clock moved backwards would otherwise hold one photo forever. */ + if (now < entry.at || now - entry.at >= WALLPAPER_TTL_MS) return null; + return entry.url; +} + +/** @param {string} url @param {any} bg @param {number} now @returns {string} */ +export function writeWallpaperCache(url, bg, now) { + return JSON.stringify({ url, key: wallpaperKey(bg), at: now }); +} + +/** @param {any} bg @param {number} [now] @returns {string|null} */ +export function loadWallpaper(bg, now = Date.now()) { + let stored = null; + try { + stored = localStorage.getItem(WALLPAPER_STORE); + } catch { + return null; + } + return readWallpaperCache(stored, bg, now); +} + +/** @param {string} url @param {any} bg @param {number} [now] @returns {void} */ +export function saveWallpaper(url, bg, now = Date.now()) { + try { + localStorage.setItem(WALLPAPER_STORE, writeWallpaperCache(url, bg, now)); + } catch {} +} diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index bd96599..de61b94 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -2,10 +2,10 @@ back. Each builder returns { el, get, control, liveValue }. See docs/widgets.md. */ -import { t } from '/js/i18n.js?v=83239bf4'; +import { t } from '/js/i18n.js?v=e644a5c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { wireChecklist } from '/js/admin-shared.js?v=1d330931'; -import { renderColorControl } from '/js/admin-color-control.js?v=bfd0955a'; +import { wireChecklist } from '/js/admin-shared.js?v=132c869f'; +import { renderColorControl } from '/js/admin-color-control.js?v=3a61c02f'; import { seedCarried, applyOptionSet, @@ -13,17 +13,24 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=d17394da'; +} from '/js/admin-logic.js?v=dcf7c37d'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; -import { qi } from '/js/utils.js?v=8ca7ce3c'; +import { qi } from '/js/utils.js?v=d949e985'; const PE = ''; const CHEV = ''; +/* A counter, not randomness. Six base-36 characters collide plausibly over a + long session, and an id that changes per run cannot be asserted on. */ +let _seq = 0; +const uniqueId = prefix => `${prefix}-${++_seq}`; + function _tag(field) { - return field.optional ? html` (optional)` : html` *`; + return field.optional + ? html` (${t('app.optional')})` + : html` *`; } function _ieRow(field, value, inputType) { @@ -33,7 +40,7 @@ function _ieRow(field, value, inputType) { row.className = 'row ie-row'; setHtml( row, - html`${field.label}${_tag(field)}${has ? value : ph}`, + html`${field.label}${_tag(field)}${has ? value : ph}`, ); const rv = row.querySelector('.rv'), inp = qi('.row-inp', row), @@ -94,7 +101,7 @@ function _secret(field, isSet) { const display = isSet ? t('common.configured') : t('common.notSet'); setHtml( row, - html`${field.label}${_tag(field)}${display}`, + html`${field.label}${_tag(field)}${display}`, ); const rv = row.querySelector('.rv'), inp = qi('.row-inp', row), @@ -310,8 +317,8 @@ function _select(field, value, ctx, config = {}) { ); if (chosen && !opts.some(o => String(o.value) === chosen)) items.unshift(html``); - if (field.optional) items.unshift(html``); - setHtml(sel, items.length ? html`${items}` : html``); + if (field.optional) items.unshift(html``); + setHtml(sel, items.length ? html`${items}` : html``); } paint(); @@ -375,7 +382,7 @@ function _pills(field, value) { row.className = 'row'; const opts = Array.isArray(field.options) ? field.options : []; let sel = value != null ? value : field.default != null ? field.default : opts[0] ? opts[0].value : ''; - const name = 'wcf-' + field.key + '-' + Math.random().toString(36).slice(2, 7); + const name = uniqueId('wcf-' + field.key); setHtml( row, html`${field.label}
${opts.map(o => html``)}
`, @@ -409,7 +416,8 @@ function _multiselect(field, value) { const cur = new Set( Array.isArray(value) ? value.map(String) : Array.isArray(field.default) ? field.default.map(String) : [], ); - const summary = () => (cur.size === 0 ? 'None selected' : cur.size + ' selected'); + const summary = () => + cur.size === 0 ? t('widgetCfg.noneSelected') : t('widgetCfg.selectedCount', { count: cur.size }); setHtml( row, html`${field.label}
`, @@ -442,8 +450,7 @@ function _multiselect(field, value) { rows. */ function _color(field, value) { const wrap = document.createElement('div'); - const idPrefix = - 'wcf-' + String(field.key).replace(/[^a-zA-Z0-9_-]/g, '') + '-' + Math.random().toString(36).slice(2, 7); + const idPrefix = uniqueId('wcf-' + String(field.key).replace(/[^a-zA-Z0-9_-]/g, '')); const initial = value != null && value !== '' ? String(value) : field.default != null ? String(field.default) : '#0289ff'; const ctl = renderColorControl(wrap, { @@ -557,7 +564,7 @@ function _group(field, rows, size, ctx) { const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'wcf-add-row'; - setHtml(addBtn, html`+ Add ${field.label}`); + setHtml(addBtn, html`${t('common.addNamed', { name: field.label })}`); addWrap.appendChild(addBtn); wrap.appendChild(addWrap); const fixed = min === max; @@ -572,7 +579,7 @@ function _group(field, rows, size, ctx) { let rowBuilt = []; function render() { - rowsHost.innerHTML = ''; + rowsHost.replaceChildren(); rowBuilt = []; data.forEach((rowData, idx) => { const hdr = document.createElement('p'); @@ -661,7 +668,7 @@ function _buildSimple(field, config, ctx) { } export function renderWidgetConfigForm(container, fields, config = {}, opts = {}) { - container.innerHTML = ''; + container.replaceChildren(); const built = []; const ctx = { widgetId: (opts && opts.widgetId) || null, diff --git a/public/js/widget-glyphs.js b/public/js/widget-glyphs.js index b672bf6..2f7a86b 100644 --- a/public/js/widget-glyphs.js +++ b/public/js/widget-glyphs.js @@ -1,11 +1,6 @@ // @ts-check -/* A glyph per widget type for the Settings item list, drawn on the 24-unit grid - the other icons in admin.js use. Inline rather than a file per glyph: they are - stroked with currentColor, so they follow the theme and the increased-contrast - block with no rule of their own, which an cannot do. - - A widget names one in its manifest. A widget that names none, or names one - that is not here, keeps the size icon. */ +/* Drawn on the same 24-unit grid as the other item-list icons, and stroked + with currentColor so they follow the theme. Do not replace with an . */ /** @type {Record} */ const PATHS = { diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js index 4f8e381..ead62f3 100644 --- a/public/js/widget-toolbox.js +++ b/public/js/widget-toolbox.js @@ -3,7 +3,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=4edf48f2'; +import { jitter } from '/js/jitter.js?v=4eeef4c9'; export { esc, html, setHtml }; @@ -237,7 +237,7 @@ export function wt(key, fallback) { /* Digit shape follows the reader's locale. 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=e2165e12'; +export { formatNumber, localiseDigits } from '/js/format-number.js?v=4a5ccef4'; export function sinceLabel(ts) { if (!ts) return ''; @@ -281,24 +281,7 @@ function _overlay(root) { }; } -/* Fetch on a timer and render, keeping the last good render through a transient - failure: errorText appears only after `staleAfter` consecutive failures, or at - once if the widget has never loaded. Pass onError to draw the error instead, - and poll shows no overlay. - - opts: { - render, (data) => void draw a successful, non-empty result - endpoint, optional data endpoint name for fetchData - fetch, optional async () => data (defaults to fetchData(endpoint)) - isEmpty, optional (data) => bool a successful but empty result - onError, optional (info) => void render your own error UI - interval = 30000, poll period in ms, or (data) => ms to vary it - with the last successful result - staleAfter = 2, consecutive failures tolerated before showing the error - root = document.body, element the status message overlays - loadingText, emptyText, errorText - } - Returns { stop }, which also detaches the visibility listener. */ +/* The options and the poll lifecycle are documented in docs/widgets.md. */ /* 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 diff --git a/public/js/widget-types.js b/public/js/widget-types.js index fab01fa..1a8e252 100644 --- a/public/js/widget-types.js +++ b/public/js/widget-types.js @@ -21,7 +21,7 @@ export const WIDGET_COST = { /* The iframe URL, from the manifest entry in `reg`. The cache version is hashed from file content at release, never maintained by hand. */ /* A `card` inside the selected view wins over the manifest's top-level one. */ -export const CARD_PRESETS = ['dark', 'light', 'translucent']; +const CARD_PRESETS = ['dark', 'light', 'translucent']; export function cardPreset(item, reg) { const entry = item?.widgetType ? reg?.[item.widgetType] : null; diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html index bcbdea5..5ed24f0 100644 --- a/public/widgets/backup/backup.html +++ b/public/widgets/backup/backup.html @@ -114,7 +114,7 @@