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