From 6d7226ad171066398058bf9890c2bace19efeab9 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:38:00 -0400 Subject: [PATCH 1/4] Describe the four icon catalogues and the variant choice --- src/content/docs/docs/adding-services.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/content/docs/docs/adding-services.md b/src/content/docs/docs/adding-services.md index 2eadc4d..05cc345 100644 --- a/src/content/docs/docs/adding-services.md +++ b/src/content/docs/docs/adding-services.md @@ -18,7 +18,11 @@ Press Add, then fill in the app. ## Icons -Type a name and Stackyard resolves it from the community [dashboard-icons](https://github.com/homarr-labs/dashboard-icons) set. Typing `sonarr` finds the Sonarr icon. You can also paste a full URL or upload a file. Uploads are stored in `./icons` and served from your own server. +Type a name and Stackyard searches four community catalogues together: [dashboard-icons](https://github.com/homarr-labs/dashboard-icons), [selfh.st](https://github.com/selfhst/icons), [simple-icons](https://github.com/simple-icons/simple-icons) and [lobehub](https://github.com/lobehub/lobe-icons). Typing `sonarr` finds the Sonarr icon. A service is matched by its name, by other spellings of it, and by the aliases a catalogue lists. Each suggestion names the catalogue it came from. + +Where a catalogue holds an icon as a light and a dark file, the picker offers a Variant choice. The choice is fixed, not theme-aware. + +You can also paste a full URL or upload a file. Uploads are stored in `./icons` and served from your own server. Icons load through the server, which fetches each one once, sanitizes SVGs, and caches it for 24 hours. The CDN does not learn which services your dashboard shows. From c4ec5d7544d3b3054b4597b87ba70ba86061a891 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:38:01 -0400 Subject: [PATCH 2/4] List every outbound host the icon picker uses --- src/content/docs/docs/security.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/content/docs/docs/security.md b/src/content/docs/docs/security.md index 3983867..6cd9ecd 100644 --- a/src/content/docs/docs/security.md +++ b/src/content/docs/docs/security.md @@ -49,13 +49,14 @@ The guard limits what a compromised widget can reach. It does not protect agains | Host | Why | | --- | --- | -| `cdn.jsdelivr.net` | The dashboard-icons set. | +| `cdn.jsdelivr.net` | Icon files, and three of the four icon catalogue indexes. | +| `raw.githubusercontent.com` | The dashboard-icons index and its files, when jsdelivr does not answer. | | Unsplash | Wallpaper, only when the wallpaper source is Unsplash. | -| `api.github.com` | The update check, when About is opened, at most once an hour. | +| `api.github.com` | The update check, when About is opened, at most once an hour. Also two icon catalogue listings, while you type in the icon picker. | Nothing else leaves your network, and no usage data is collected. -Icons load through the server, which caches each one for 24 hours, so the CDN does not learn which services your dashboard shows. Set the wallpaper to an image or a solid colour and Unsplash is never contacted. +Icons load through the server, which caches each one for 24 hours, so the CDN does not learn which services your dashboard shows. A catalogue index is read only while you type in the icon picker, and is cached for 24 hours. A dashboard load fetches none of them. Set the wallpaper to an image or a solid colour and Unsplash is never contacted. ## The container From 69bd19e1bd6cd20ad618577ab17bbb02414d99e4 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:38:02 -0400 Subject: [PATCH 3/4] Correct the import message and add the catalogue failure --- src/content/docs/docs/troubleshooting.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/content/docs/docs/troubleshooting.md b/src/content/docs/docs/troubleshooting.md index 4e22c2e..1085f92 100644 --- a/src/content/docs/docs/troubleshooting.md +++ b/src/content/docs/docs/troubleshooting.md @@ -245,7 +245,8 @@ Messages shown in the admin, and what each one means. | `Wallpaper failed: ` | The image was not stored. The link was unreachable, the file is not a JPEG, PNG, WebP, AVIF or GIF, or it is over 16 MB. | | `Set a password before turning authentication on.` | Authentication needs a password to exist first. | | `: children point at items that are not here: ...` | A folder in the config lists apps that are not in the same file. The named entries are missing. Usually a hand-edited or partly merged export. | -| `Nothing to import, the file matches your current config.` | The imported file is identical to what is already stored. | +| `Nothing to import. The file matches your current config.` | The imported file is identical to what is already stored. | +| `Icon catalogues could not be reached` | No catalogue answered while searching. The field still takes a full URL or an upload. | | ` is not a gethomepage or Dashy config.` | Only those two formats are recognised. See [Migrating](/docs/import-export/migrating/). | | `(may be out of date)` | A stale reading. The last poll failed and the previous value is shown. | From 830af0b247fa07ac6c318020bf7f67654dec845e Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:38:03 -0400 Subject: [PATCH 4/4] Sync the bundled widget and dashboard code --- public/js/admin-app-form.js | 370 +++++++++++++----- public/js/admin-auth.js | 4 +- public/js/admin-color-control.js | 4 +- public/js/admin-drag.js | 2 +- public/js/admin-list.js | 10 +- public/js/admin-logic.js | 12 + public/js/admin-settings.js | 6 +- public/js/admin-shared.js | 9 +- public/js/admin-widget-form.js | 8 +- public/js/admin.js | 27 +- public/js/dashboard.js | 16 +- public/js/fluid-hover.js | 133 +++++++ public/js/glide-select.js | 57 +++ public/js/icons.js | 56 ++- public/js/moving-pill.js | 68 ++++ public/js/spotlight.js | 5 +- public/js/ui.js | 11 +- public/js/utils.js | 2 +- public/js/widget-config-form.js | 8 +- public/widgets/books/i18n/fa.json | 6 +- public/widgets/connections/i18n/de.json | 4 +- public/widgets/connections/i18n/es.json | 4 +- public/widgets/connections/i18n/fa.json | 4 +- public/widgets/connections/i18n/fr.json | 4 +- public/widgets/connections/i18n/zh-Hans.json | 4 +- .../dashboard-switch/i18n/zh-Hans.json | 2 +- public/widgets/disk-health/i18n/fa.json | 6 +- public/widgets/dns/i18n/fa.json | 14 +- public/widgets/github/i18n/fa.json | 6 +- public/widgets/nowplaying/i18n/fa.json | 6 +- public/widgets/system-summary/i18n/fa.json | 10 +- public/widgets/weather/i18n/fa.json | 4 +- 32 files changed, 691 insertions(+), 191 deletions(-) create mode 100644 public/js/fluid-hover.js create mode 100644 public/js/glide-select.js create mode 100644 public/js/moving-pill.js diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index c5bc669..aa3d636 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,6 +1,6 @@ -import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=e8b2a9f7'; +import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=970a91b0'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { loadLocalIcons, resolveIcon, iconChain, cdnIconName } from '/js/icons.js?v=69c2b9bd'; +import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=04e7796e'; import { state } from '/js/admin-state.js?v=7d68e98e'; import { isDockBlocked, @@ -8,7 +8,9 @@ import { clearsStoredSecret, isBareHostUrl, failureIsMissingApiPath, -} from '/js/admin-logic.js?v=dcf7c37d'; + nextActiveIndex, + sameIconName, +} from '/js/admin-logic.js?v=74cb4272'; import { t } from '/js/i18n.js?v=e644a5c5'; import { toast, @@ -19,10 +21,11 @@ import { initInlineEdit, setTogDisabled, wireChecklist, -} from '/js/admin-shared.js?v=70ec6be1'; +} from '/js/admin-shared.js?v=0f36d0dc'; import { MAX_LABELS } from '/js/badge-logic.js?v=b3c8b6c2'; -import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=2d28867b'; +import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=d162431d'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; export function buildFolderForm(body, item) { const children = item?.children || []; @@ -127,13 +130,17 @@ export function buildAppForm(body, item) {

${t('app.icon')}

+
${state.siurl ? html`` : html`${(item?.label || '?')[0]?.toUpperCase() || '?'}`} - +
-
+
+
+

+
@@ -508,47 +515,187 @@ function _ieRow(rowId, label, inpId, val, ph, type = 'text') { return html`
${label}${has ? val : ph}
`; } +const SOURCE_LABEL = { + di: 'dashboardicons', + selfhst: 'selfh.st', + simple: 'simple-icons', + lobe: 'lobehub', +}; + +const VARIANT_LABEL = { base: 'app.iconVariantBase', light: 'app.iconVariantLight', dark: 'app.iconVariantDark' }; + +let ipList = []; +let ipActive = -1; + +function ipClose() { + const rs = el('iprs'); + if (!rs) return; + rs.classList.remove('open'); + rs.replaceChildren(); + inpById('ip-in')?.setAttribute('aria-expanded', 'false'); + inpById('ip-in')?.removeAttribute('aria-activedescendant'); + fluidHoverClear(rs); + ipList = []; + ipActive = -1; +} + +function ipSay(msg) { + const s = el('ip-status'); + if (s) s.textContent = msg; +} + +function ipMessage(main, hint = '') { + const rs = el('iprs'); + if (!rs) return; + ipList = []; + ipActive = -1; + const d = document.createElement('p'); + d.className = 'ipr-msg'; + const strong = document.createElement('strong'); + strong.textContent = main; + d.append(strong); + if (hint) { + d.append(document.createElement('br')); + d.append(document.createTextNode(hint)); + } + rs.replaceChildren(d); + rs.classList.add('open'); + inpById('ip-in')?.setAttribute('aria-expanded', 'true'); + inpById('ip-in')?.removeAttribute('aria-activedescendant'); +} + +function ipSetActive(i, { scroll = true } = {}) { + const rs = el('iprs'); + const opts = qa('.ipr', rs); + if (!opts.length || i == null) return; + ipActive = i; + opts.forEach((o, n) => { + const on = n === ipActive; + o.classList.toggle('kb-active', on); + o.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + const act = opts[ipActive]; + inpById('ip-in')?.setAttribute('aria-activedescendant', act.id); + if (scroll) act.scrollIntoView({ block: 'nearest' }); + fluidHoverKb(act); +} + +function ipChoose(i) { + const pick = ipList[i]; + if (!pick) return; + setIconRef(pick.ref); + renderIconVariants([]); + ipClose(); + inpById('ip-in')?.focus(); + loadIconVariants(pick.ref); +} + +function setIconRef(ref) { + state.siurl = ref; + const inp = inpById('ip-in'); + if (inp) inp.value = ref; + updPrev(); +} + +/* Which file suits the dashboard is the user's choice, never the admin theme. */ +function renderIconVariants(variants) { + const slot = el('icon-variant-slot'); + if (!slot) return; + if (!variants.length) return void slot.replaceChildren(); + setHtml( + slot, + html`
+ ${t('app.iconVariant')} +
+ ${variants.map( + v => + html``, + )} +
+
`, + ); + slot.querySelectorAll('input').forEach(r => { + r.onchange = () => { + if (r.checked) setIconRef(r.value); + }; + }); +} + +/* Only the catalogue knows which files an icon has. */ +async function loadIconVariants(ref) { + if (!ref || ref.startsWith('http://') || ref.startsWith('https://') || ref.includes('/')) return; + try { + const d = await ag(`/api/icons/variants?ref=${encodeURIComponent(ref)}`); + if (inpById('ip-in')?.value !== ref) return; + const variants = d.variants || []; + /* The catalogue lists this name but holds no file under it. */ + if (variants.length && !variants.some(v => v.ref === ref)) setIconRef(variants[0].ref); + renderIconVariants(variants); + } catch {} +} + function wireIcon() { const inp = inpById('ip-in'), rs = el('iprs'); if (!inp) return; - let t; + let t0; + /* Each keystroke starts a search and the slow ones finish last. The token + stops a stale result replacing a newer one. */ + let run = 0; + const search = async v => { + const mine = ++run; + const q = v.replace(/\.(svg|png|ico)$/i, ''); + try { + const d = await ag(`/api/icons/search?q=${encodeURIComponent(q)}`); + if (mine !== run) return; + showIPRes(d.results || [], v); + } catch { + if (mine !== run) return; + /* No catalogue to confirm against, so the typed name is all that is left. */ + updPrev(); + ipMessage(t('app.iconSearchFailed'), t('app.iconTryUpload')); + ipSay(t('app.iconSearchFailed')); + } + }; inp.oninput = () => { const v = inp.value.trim(); - if (v.startsWith('http://') || v.startsWith('https://')) { - state.siurl = v; + clearTimeout(t0); + state.siurl = v; + /* Nothing to look up: empty, an address, or a file already on this server. */ + if (!v || v.startsWith('http://') || v.startsWith('https://') || v.includes('/') || resolveIcon(v)) { + run++; updPrev(); - rs.classList.remove('open'); + ipClose(); + renderIconVariants([]); return; } - if (v && !v.includes('/')) { - state.siurl = v; - updPrev(); - clearTimeout(t); - t = setTimeout(async () => { - const q = v.replace(/\.(svg|png)$/i, ''); - try { - const d = await ag(`/api/icons/search?q=${encodeURIComponent(q)}`); - showIPRes(d.results || [], v); - } catch { - rs.classList.remove('open'); - } - }, 300); - return; + /* A half-typed name is never requested. Each miss is held for a day in a + cache of 300, so typing evicts icons that do exist. */ + ipMessage(t('app.iconSearching')); + t0 = setTimeout(() => search(v), 300); + }; + + inp.onkeydown = e => { + const open = rs?.classList.contains('open'); + /* Arrows only. nextActiveIndex also answers Home and End, which belong to + the caret while focus is in a text field. */ + if (ipList.length && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + const moved = nextActiveIndex(e.key, ipActive, ipList.length); + if (moved != null) { + e.preventDefault(); + return void ipSetActive(moved); + } } - clearTimeout(t); - if (!v) { - rs.classList.remove('open'); - return; + if (e.key === 'Enter' && ipActive >= 0) { + e.preventDefault(); + return void ipChoose(ipActive); } - t = setTimeout(async () => { - try { - const d = await ag(`/api/icons/search?q=${encodeURIComponent(v)}`); - showIPRes(d.results || [], v); - } catch { - rs.classList.remove('open'); - } - }, 300); + if (e.key === 'Escape' && open) { + e.preventDefault(); + e.stopPropagation(); + ipClose(); + } + if (e.key === 'Tab' && open) ipClose(); }; const upInput = inpById('ip-upload'); @@ -570,6 +717,8 @@ function wireIcon() { state.siurl = d.filename; const ipIn = inpById('ip-in'); if (ipIn) ipIn.value = d.filename; + renderIconVariants([]); + ipClose(); updPrev(); toast(t('toast.uploaded', { name: d.filename })); } catch (e) { @@ -582,76 +731,93 @@ function wireIcon() { } document.addEventListener('click', e => { - if (!el('ipw')?.contains(/** @type {Node} */ (e.target))) rs?.classList.remove('open'); + if (!el('ipw')?.contains(/** @type {Node} */ (e.target))) ipClose(); }); + loadIconVariants(state.siurl); +} + +function ipRow(i, iconRef, name, source, urls = [], variants = []) { + const r = document.createElement('button'); + r.type = 'button'; + r.className = 'ipr'; + r.id = `ipr-${i}`; + r.setAttribute('role', 'option'); + r.setAttribute('aria-selected', 'false'); + const img = document.createElement('img'); + img.alt = ''; + /* Ordered by the catalogue, whose first entry is the file it actually holds. */ + const srcs = [...urls, ...variants.flatMap(v => v.urls || [])].filter(Boolean); + if (!srcs.length) srcs.push(...iconChain(iconRef)); + let step = 0; + img.src = srcs[0] || ''; + img.onerror = () => { + step++; + if (step < srcs.length) img.src = srcs[step]; + else { + img.onerror = null; + img.removeAttribute('src'); + img.style.visibility = 'hidden'; + } + }; + const sp = document.createElement('span'); + sp.className = 'ipr-name'; + /* A Latin name truncates at the wrong end in a right-to-left locale without + this, and unicode-bidi:plaintext does nothing in WebKit. */ + sp.dir = 'auto'; + sp.textContent = name; + r.append(img, sp); + if (source) { + const src = document.createElement('span'); + src.className = 'ipr-src'; + src.textContent = t('app.iconFrom', { source: SOURCE_LABEL[source] || source }); + r.append(src); + } + /* Enter acts on the cursor, so the pointer has to move all of it: a row that + is announced as active while another is marked selected sends Enter to a + row nothing is pointing at. */ + r.onpointerenter = () => ipSetActive(i, { scroll: false }); + r.onclick = () => ipChoose(i); + return r; } + function showIPRes(list, rawInput) { const rs = el('iprs'); if (!rs) return; - rs.replaceChildren(); - list.forEach(ic => { - const r = document.createElement('button'); - r.type = 'button'; - r.className = 'ipr'; - const img = document.createElement('img'); - img.alt = ''; - img.src = ic.svgUrl; - img.onerror = () => { - img.src = ic.pngUrl; - }; - const sp = document.createElement('span'); - sp.textContent = ic.name; - r.append(img, sp); - r.onclick = () => { - state.siurl = ic.svgUrl; - inpById('ip-in').value = ic.svgUrl; - updPrev(); - rs.classList.remove('open'); - }; - rs.appendChild(r); - }); - if (!list.length && rawInput && !rawInput.includes('/')) { - const val = cdnIconName(rawInput); - if (!val) { - rs.classList.remove('open'); - return; - } - const srcs = iconChain(val); - if (!srcs.length) { - rs.classList.remove('open'); - return; - } - const r = document.createElement('button'); - r.type = 'button'; - r.className = 'ipr'; - const img = document.createElement('img'); - img.alt = ''; - img.style.cssText = 'width:24px;height:24px;object-fit:contain;'; - let step = 0; - img.src = srcs[0]; - img.onerror = () => { - step++; - if (step < srcs.length) img.src = srcs[step]; - else { - img.onerror = null; - img.src = ''; - img.style.display = 'none'; - } - }; - const sp = document.createElement('span'); - sp.textContent = val; - r.append(img, sp); - r.onclick = () => { - state.siurl = val; - inpById('ip-in').value = val; - updPrev(); - rs.classList.remove('open'); - }; - rs.appendChild(r); + const typed = cdnIconRef(rawInput); + const match = list.find( + ic => sameIconName(ic.name, rawInput) || sameIconName(splitIconRef(ic.ref).slug, rawInput) || ic.ref === typed, + ); + if (match) { + /* The tile draws the matched catalogue's file, so the saved value has to be + that catalogue's reference. Keeping the typed text would save a name that + resolves to a different catalogue and loads nothing. */ + state.siurl = match.ref; + updPrev(match.urls); + } else if (!list.length) updPrev(); + if (!list.length) { + if (!typed) return ipClose(); + /* The name may still be a file a catalogue holds without listing it. */ + ipList = [{ ref: typed, name: typed, source: '', variants: [] }]; + rs.replaceChildren(ipRow(0, typed, typed, '')); + const msg = document.createElement('p'); + msg.className = 'ipr-msg'; + msg.textContent = t('app.iconTryUpload'); + rs.append(msg); + rs.classList.add('open'); + inpById('ip-in')?.setAttribute('aria-expanded', 'true'); + ipActive = -1; + ipSay(t('app.iconNoMatch', { q: rawInput })); + return; } - if (rs.children.length) rs.classList.add('open'); - else rs.classList.remove('open'); + ipList = list; + ipActive = -1; + rs.replaceChildren(...list.map((ic, i) => ipRow(i, ic.ref, ic.name, ic.source, ic.urls, ic.variants))); + rs.classList.add('open'); + inpById('ip-in')?.setAttribute('aria-expanded', 'true'); + inpById('ip-in')?.removeAttribute('aria-activedescendant'); + ipSay(t('app.iconResultCount', { count: list.length })); } + function setInitialGlyph(p) { const l = inpById('f-lbl')?.value || '?'; const s = document.createElement('span'); @@ -661,7 +827,7 @@ function setInitialGlyph(p) { /* Several attempts are in flight at once and the half-typed ones finish last. The token stops a stale failure replacing a finished preview. */ let prevRun = 0; -function updPrev() { +function updPrev(explicit) { const p = el('ipv'); if (!p) return; const run = ++prevRun; @@ -670,7 +836,7 @@ function updPrev() { setInitialGlyph(p); return; } - const fallbacks = iconChain(state.siurl); + const fallbacks = explicit?.length ? explicit : iconChain(state.siurl); if (!fallbacks.length) { setInitialGlyph(p); return; diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index f500868..8baf8ca 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=70ec6be1'; +import { ag, ap } from '/js/admin-shared.js?v=0f36d0dc'; 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=e8b2a9f7'; +import { el, inp as inpById, qa } from '/js/utils.js?v=970a91b0'; export async function checkAuth(onLogin) { try { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index 63dd3d9..6606422 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=70ec6be1'; +import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=0f36d0dc'; 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=e8b2a9f7'; +import { qa, q } from '/js/utils.js?v=970a91b0'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; export const BADGE_DEFAULT = '#1e6ef4'; diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js index e360eb3..8de314b 100644 --- a/public/js/admin-drag.js +++ b/public/js/admin-drag.js @@ -12,7 +12,7 @@ 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=4f71ef6c'; -import { qa } from '/js/utils.js?v=e8b2a9f7'; +import { qa } from '/js/utils.js?v=970a91b0'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/public/js/admin-list.js b/public/js/admin-list.js index b4ec511..eff0d49 100644 --- a/public/js/admin-list.js +++ b/public/js/admin-list.js @@ -13,13 +13,13 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e'; import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c'; -import { reorderItems } from '/js/admin-logic.js?v=dcf7c37d'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=f3b01482'; -import { paintIcon } from '/js/admin-shared.js?v=70ec6be1'; -import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=e8b2a9f7'; +import { reorderItems } from '/js/admin-logic.js?v=74cb4272'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=3ffdf185'; +import { paintIcon } from '/js/admin-shared.js?v=0f36d0dc'; +import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=970a91b0'; 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=997ca7c6'; +import { sizeLabel } from '/js/admin-widget-form.js?v=0cb044d0'; import { widgetGlyph } from '/js/widget-glyphs.js?v=12b0a947'; /** @type {{ openModal: (idx: number|null) => void, diff --git a/public/js/admin-logic.js b/public/js/admin-logic.js index c15d798..780fc25 100644 --- a/public/js/admin-logic.js +++ b/public/js/admin-logic.js @@ -103,6 +103,18 @@ export function collectFieldValues(reads, { includeTransient = false } = {}) { return out; } +/* Loose, so "AdGuard Home", "adguard_home" and "adguard-home" are one name. */ +export function sameIconName(a, b) { + const norm = s => + String(s ?? '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/g, ''); + const x = norm(a); + return !!x && x === norm(b); +} + export function nextActiveIndex(key, active, len) { if (len <= 0) return null; const clamp = i => Math.max(0, Math.min(i, len - 1)); diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index daf7b60..4b452c1 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,9 +1,9 @@ -import { toast, ag, ap } from '/js/admin-shared.js?v=70ec6be1'; +import { toast, ag, ap } from '/js/admin-shared.js?v=0f36d0dc'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { t } from '/js/i18n.js?v=e644a5c5'; -import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=dcf7c37d'; +import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=74cb4272'; import { confirmText } from '/js/modal.js?v=11fa1eff'; -import { el, inp, q, qa, setUserText } from '/js/utils.js?v=e8b2a9f7'; +import { el, inp, q, qa, setUserText } from '/js/utils.js?v=970a91b0'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ let _passwordSet = false; diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index 6a16ae7..d158cb8 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,8 +1,9 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=dcf7c37d'; -import { el, qa, q } from '/js/utils.js?v=e8b2a9f7'; +import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=74cb4272'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; +import { el, qa, q } from '/js/utils.js?v=970a91b0'; import { t } from '/js/i18n.js?v=e644a5c5'; -import { iconChain } from '/js/icons.js?v=69c2b9bd'; +import { iconChain } from '/js/icons.js?v=04e7796e'; export const API = ''; @@ -218,6 +219,7 @@ export function wireChecklist(dd, btn, list, onToggle) { li.classList.toggle('kb-active', n === active); }); o[active].focus(); + fluidHoverKb(o[active]); }; /* 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 @@ -244,6 +246,7 @@ export function wireChecklist(dd, btn, list, onToggle) { list.hidden = true; btn.setAttribute('aria-expanded', 'false'); opts().forEach(li => li.classList.remove('kb-active')); + fluidHoverClear(list); if (focusBtn) btn.focus(); }; const toggle = li => { diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 5d2ecfe..49ae244 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=7d68e98e'; -import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=70ec6be1'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=e6ec5157'; +import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=0f36d0dc'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=1b131080'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=dcf7c37d'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=74cb4272'; import { t } from '/js/i18n.js?v=e644a5c5'; -import { q, qi, qa } from '/js/utils.js?v=e8b2a9f7'; +import { q, qi, qa } from '/js/utils.js?v=970a91b0'; const SIZE_ICONS = { small: diff --git a/public/js/admin.js b/public/js/admin.js index 6a5cef3..553b5a9 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=8c9ef961'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=1bb8584a'; -import { initList, render, syncFilterUI } from '/js/admin-list.js?v=2067483a'; -import { resolveAdminSection } from '/js/admin-logic.js?v=dcf7c37d'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=92c86f9e'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=ee5255ba'; +import { initList, render, syncFilterUI } from '/js/admin-list.js?v=0c33cd56'; +import { resolveAdminSection } from '/js/admin-logic.js?v=74cb4272'; import { buildAppItem, claimFolderChildren, @@ -10,13 +10,15 @@ import { snapshotItems, upsertItem, } from '/js/admin-save-logic.js?v=4f71ef6c'; -import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=9f7cf590'; -import { ag, ap, initInlineEdit, paintIcon, setReauthHandler, toast } from '/js/admin-shared.js?v=70ec6be1'; +import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=23830fd0'; +import { ag, ap, initInlineEdit, paintIcon, setReauthHandler, toast } from '/js/admin-shared.js?v=0f36d0dc'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=997ca7c6'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=0cb044d0'; +import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; +import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=e644a5c5'; -import { loadLocalIcons } from '/js/icons.js?v=69c2b9bd'; +import { loadLocalIcons } from '/js/icons.js?v=04e7796e'; import { clearSkipTls, convert, @@ -29,8 +31,8 @@ import { 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=e8b2a9f7'; -import { normalizeColorInput } from '/js/admin-color-control.js?v=2d28867b'; +import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=970a91b0'; +import { normalizeColorInput } from '/js/admin-color-control.js?v=d162431d'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; @@ -39,7 +41,10 @@ import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6' dashboard, so the two screens cannot disagree about what mobile means. */ function _syncMobile(mobile) { document.documentElement.classList.toggle('is-mobile', mobile); + syncGlideSelect(); } +initFluidHover(); + const _mobileAtLoad = isMobileLayout(); _syncMobile(_mobileAtLoad); onLayoutChange(_syncMobile, _mobileAtLoad); @@ -606,11 +611,13 @@ function initNav() { s.hidden = s.id !== 'sec-' + id; }); links.forEach(l => l.classList.toggle('active', l.dataset.sec === id)); + syncGlideSelect(); localStorage.setItem(STORE, id); } links.forEach(l => l.addEventListener('click', () => show(l.dataset.sec))); addEventListener('hashchange', () => show(location.hash.slice(1))); show(location.hash.slice(1) || localStorage.getItem(STORE)); + initGlideSelect(); } function initAllInlineEdits() { diff --git a/public/js/dashboard.js b/public/js/dashboard.js index cadb38b..d255445 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -1,4 +1,4 @@ -import { loadLocalIcons, iconChain } from '/js/icons.js?v=69c2b9bd'; +import { loadLocalIcons, iconChain } from '/js/icons.js?v=04e7796e'; import { WIDGET_HEIGHTS, WIDGET_DESIGN, @@ -24,13 +24,14 @@ import { setUserText, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=e8b2a9f7'; -import { initSpotlight } from '/js/spotlight.js?v=c81114db'; +} from '/js/utils.js?v=970a91b0'; +import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; +import { initSpotlight } from '/js/spotlight.js?v=2b722c16'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; 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=3c2c7d0b'; +import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=08ec9951'; 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'; @@ -845,6 +846,7 @@ async function boot() { }; _stateRef = state; initUI(state); + initFluidHover(); initSpotlight({ getItems: () => items, isMob: () => MOB, CB, iconChain, openFolderDesktop, openFolderMobile }); /* Mobile measures the viewport as it builds, so it waits for layout. */ @@ -936,11 +938,13 @@ async function boot() { document.body.appendChild(probe); let _sat, _saH = -1, + _saW = -1, _saFirst = true; new ResizeObserver(entries => { - const h = entries[0].contentRect.height; - if (h === _saH) return; + const { width: w, height: h } = entries[0].contentRect; + if (h === _saH && w === _saW) return; _saH = h; + _saW = w; /* Observing delivers the current size at once, and the layout being built now already has it. */ if (_saFirst) { diff --git a/public/js/fluid-hover.js b/public/js/fluid-hover.js new file mode 100644 index 0000000..61233dd --- /dev/null +++ b/public/js/fluid-hover.js @@ -0,0 +1,133 @@ +/* A single highlight per list, moved instead of redrawn. It follows the + pointer, and falls back to the keyboard cursor when the pointer leaves. */ + +import { ensurePill, pillOf, placePill, restPill } from '/js/moving-pill.js?v=4e961dec'; + +const FINE = '(hover: hover) and (pointer: fine)'; + +/* Container element → { hover, kb, bound }. */ +const states = new WeakMap(); +/* Containers holding a live pill, so a resize can reposition them. Weakly + held: the admin rebuilds these lists on every render, and a strong set kept + every detached one alive until the window happened to be resized. */ +const live = new Set(); + +function dropDetached() { + for (const c of live) if (!c.isConnected) live.delete(c); +} + +const LISTS = [ + { container: '#sres', item: '.sr' }, + { container: '.row-dd-list', item: 'li[role="option"]' }, + { container: '#iprs', item: '.ipr' }, + { container: '.grp', item: '.row-link, .row.drow' }, +]; + +export function fluidHoverSupported() { + return typeof matchMedia === 'function' && matchMedia(FINE).matches; +} + +function selectable(item) { + if (item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true') return false; + return !item.classList.contains('d-none') && !item.classList.contains('dragging'); +} + +function stateFor(container) { + let s = states.get(container); + if (!s) { + s = { hover: null, kb: null, bound: false }; + states.set(container, s); + } + return s; +} + +function refresh(container) { + const s = stateFor(container); + /* Whichever moved last. The pointer holding it meant a keyboard cursor in a + hovered list had no highlight at all, because the row's own background is + suppressed while the pill is on, and Enter then acted on an invisible row. */ + const target = s.hover || s.kb; + if (target && target.isConnected && selectable(target)) { + const pill = ensurePill(container, 'fh-hl'); + live.add(container); + placePill(container, pill, target); + } else { + restPill(pillOf(container)); + live.delete(container); + } + dropDetached(); +} + +function bind(container) { + const s = stateFor(container); + if (s.bound) return; + s.bound = true; + container.addEventListener('pointerleave', () => { + s.hover = null; + refresh(container); + }); +} + +function locate(item) { + if (!item || !item.matches) return null; + for (const cfg of LISTS) { + if (!item.matches(cfg.item)) continue; + const container = item.closest(cfg.container); + if (container) return container; + } + return null; +} + +/* Moves the highlight to the keyboard cursor, and takes it off the pointer: + the last input to move is the one that owns it. */ +export function fluidHoverKb(item) { + if (!fluidHoverSupported()) return; + const container = locate(item); + if (!container) return; + const s = stateFor(container); + s.hover = null; + s.kb = item; + bind(container); + refresh(container); +} + +/* For the test: proves a detached container is not retained. */ +export function _liveHolds(container) { + dropDetached(); + return live.has(container); +} + +export function fluidHoverClear(container) { + const s = states.get(container); + if (!s) return; + s.hover = null; + s.kb = null; + refresh(container); +} + +export function initFluidHover(root = document) { + if (!fluidHoverSupported()) return; + root.addEventListener( + 'pointerover', + e => { + if (e.pointerType && e.pointerType !== 'mouse') return; + const from = /** @type {Element} */ (e.target); + if (!from || !from.closest) return; + for (const cfg of LISTS) { + const item = from.closest(cfg.item); + if (!item) continue; + const container = item.closest(cfg.container); + if (!container || !selectable(item)) continue; + stateFor(container).hover = item; + bind(container); + refresh(container); + return; + } + }, + true, + ); + addEventListener('resize', () => { + dropDetached(); + for (const c of live) refresh(c); + }); +} diff --git a/public/js/glide-select.js b/public/js/glide-select.js new file mode 100644 index 0000000..89b18ae --- /dev/null +++ b/public/js/glide-select.js @@ -0,0 +1,57 @@ +/* The selected-item pill in the settings navigation. It travels from the old + item to the new one instead of fading in place. + + Not gated on a pointer: this is selection, not hover, so the phone tab bar + gets it too. */ + +import { ensurePill, measurable, placePill } from '/js/moving-pill.js?v=4e961dec'; + +const NAVS = [ + { container: '.sb-nav', item: '.nl', holdInk: true }, + { container: '.mtabbar', item: '.mtab', holdInk: false }, +]; + +/* Container → the item its pill is on, so a move is told from a reposition. */ +const at = new WeakMap(); +/* Container → the pending timer that lifts the ink hold. */ +const timers = new WeakMap(); +const GLIDE_MS = 320; + +/** Places each nav's pill on its active item. Both navs are in the document at + once and the one belonging to the other layout is hidden, so the hidden one + is left alone rather than placed against a zero rect. */ +export function syncGlideSelect(root = document) { + for (const cfg of NAVS) { + for (const container of root.querySelectorAll(cfg.container)) { + if (!measurable(container)) continue; + const active = container.querySelector(cfg.item + '.active'); + if (!active) continue; + const was = at.get(container); + /* The label ink is held only for a real move. On the first placement + there is nothing to travel from, so the pill and the ink land together. */ + if (cfg.holdInk && was && was !== active) { + container.classList.add('gs-moving'); + clearTimeout(timers.get(container)); + timers.set( + container, + setTimeout(() => container.classList.remove('gs-moving'), GLIDE_MS), + ); + } + if (placePill(container, ensurePill(container, 'gs-pill'), active)) at.set(container, active); + } + } +} + +/* Watch the navs rather than the window. A nav is laid out later than this + runs: the tab bar stays hidden until the page is marked authenticated, and + the two navs swap when the layout crosses the mobile breakpoint. Observing + the container catches every one of those without a caller having to know. */ +export function initGlideSelect(root = document) { + if (typeof ResizeObserver === 'function') { + const ro = new ResizeObserver(() => syncGlideSelect(root)); + for (const cfg of NAVS) for (const nav of root.querySelectorAll(cfg.container)) ro.observe(nav); + } else { + addEventListener('resize', () => syncGlideSelect(root)); + } + syncGlideSelect(root); +} diff --git a/public/js/icons.js b/public/js/icons.js index 3b09eb0..052dec2 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -31,6 +31,29 @@ export function resolveIcon(raw) { return LOCAL_ICONS.has(filename) ? iconPath(filename) : ''; } +/* The catalogues a name can come from, in the order the picker ranks them. A + bare name is dashboard-icons: every icon saved before the other three + existed is written that way. */ +const SOURCES = ['di', 'selfhst', 'simple', 'lobe']; + +const CDN_BASE = { + di: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons', + selfhst: 'https://cdn.jsdelivr.net/gh/selfhst/icons', + simple: 'https://cdn.jsdelivr.net/gh/simple-icons/simple-icons/icons', + lobe: 'https://cdn.jsdelivr.net/gh/lobehub/lobe-icons/packages/static-svg/icons', +}; + +/** Split a stored reference into its catalogue and slug. */ +export function splitIconRef(raw) { + const s = String(raw || '').trim(); + const i = s.indexOf(':'); + if (i > 0) { + const source = s.slice(0, i); + if (SOURCES.includes(source)) return { source, slug: s.slice(i + 1) }; + } + return { source: 'di', slug: s }; +} + /* CDN names only. The catalogue is lowercase, hyphenated and case-sensitive; a local filesystem may hold two names differing by case. */ export function cdnIconName(name) { @@ -42,6 +65,19 @@ export function cdnIconName(name) { .replace(/^-|-$/g, ''); } +/** Keep the catalogue prefix, normalise only the slug after it. */ +export function cdnIconRef(raw) { + const { source, slug } = splitIconRef(raw); + const name = cdnIconName(slug); + if (!name) return ''; + return source === 'di' ? name : `${source}:${name}`; +} + +function cdnFileUrl(source, slug, ext) { + if (source === 'simple' || source === 'lobe') return ext === 'svg' ? `${CDN_BASE[source]}/${slug}.svg` : ''; + return `${CDN_BASE[source]}/${ext}/${slug}.${ext}`; +} + export function iconChain(rawIcon) { if (!rawIcon) return []; const localUrl = resolveIcon(rawIcon); @@ -58,16 +94,22 @@ export function iconChain(rawIcon) { if (localUrl && rawIcon !== localUrl) chain.push(rawIcon); if (!localUrl) chain.push(rawIcon); } else { - const cdn = encodeURIComponent(cdnIconName(name)); + 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. */ - if (!explicitExt || explicitExt === 'svg') chain.push(`/api/icons/cdn?name=${cdn}&ext=svg`); - if (!explicitExt || explicitExt === 'png') chain.push(`/api/icons/cdn?name=${cdn}&ext=png`); - if (!explicitExt || explicitExt === 'svg') - chain.push(`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${cdn}.svg`); - if (!explicitExt || explicitExt === 'png') - chain.push(`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/${cdn}.png`); + /* 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 (explicitExt) chain.push(`/api/icons/cdn?name=${cdn}&ext=${explicitExt}${src}`); + else 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); + } } return chain; } diff --git a/public/js/moving-pill.js b/public/js/moving-pill.js new file mode 100644 index 0000000..f53b85c --- /dev/null +++ b/public/js/moving-pill.js @@ -0,0 +1,68 @@ +/* One element moved between the rows of a list, instead of a fill redrawn on + each row. Shared by the hover highlight and the navigation selection. + + The pill is prepended, so it paints before its siblings and they cover it. + Give it no z-index: one on a row makes that row a stacking context, and an + overlay inside the row is then trapped behind every row that follows. */ + +/** @param {Element} container @returns {HTMLElement|null} */ +export function pillOf(container) { + const first = container.firstElementChild; + return first && first.classList.contains('mp') ? /** @type {HTMLElement} */ (first) : null; +} + +/* Created on demand, never restored after a re-render: a child that is always + there makes `:empty` false, which keeps an empty list on screen. */ +export function ensurePill(container, variant) { + let p = pillOf(container); + if (!p) { + p = document.createElement('div'); + p.classList.add('mp', variant); + p.setAttribute('aria-hidden', 'true'); + container.prepend(p); + } + return p; +} + +/** Measurable means laid out: a nav hidden for the other layout reports zeroes, + and placing against those puts the pill in the corner. + @param {Element} el */ +export function measurable(el) { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; +} + +/** @param {Element} container @param {HTMLElement} pill @param {Element} item */ +export function placePill(container, pill, item) { + if (!measurable(container) || !measurable(item)) return false; + const cr = container.getBoundingClientRect(); + const ir = item.getBoundingClientRect(); + const cs = getComputedStyle(item); + pill.style.borderRadius = [ + cs.borderTopLeftRadius, + cs.borderTopRightRadius, + cs.borderBottomRightRadius, + cs.borderBottomLeftRadius, + ].join(' '); + pill.style.width = ir.width + 'px'; + pill.style.height = ir.height + 'px'; + /* The pill is anchored at the inline start, so right-to-left measures from + the right edge and travels the other way. None of these lists scroll + sideways, so the rect difference is the whole inline offset. */ + const rtl = cs.direction === 'rtl'; + const inline = (rtl ? cr.right - ir.right : ir.left - cr.left) - container.clientLeft; + const block = ir.top - cr.top - container.clientTop + container.scrollTop; + pill.style.transform = 'translate3d(' + (rtl ? -inline : inline) + 'px,' + block + 'px,0)'; + if (!pill.classList.contains('on')) { + /* `.on` carries the transform transition, so it has to land in a later + style pass or the pill slides in from the corner. */ + pill.getBoundingClientRect(); + pill.classList.add('on'); + } + return true; +} + +/** @param {HTMLElement|null} pill */ +export function restPill(pill) { + if (pill) pill.classList.remove('on'); +} diff --git a/public/js/spotlight.js b/public/js/spotlight.js index 6f1ce91..8121b42 100644 --- a/public/js/spotlight.js +++ b/public/js/spotlight.js @@ -1,4 +1,5 @@ -import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=e8b2a9f7'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; +import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=970a91b0'; import { t } from '/js/i18n.js?v=e644a5c5'; /* Attached to the window so a re-open can undo the previous one. */ @@ -130,6 +131,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto f.appendChild(a); }); res.appendChild(f); + fluidHoverClear(res); if (live) live.textContent = cur.length + ' ' + (cur.length === 1 ? t('home.result') : t('home.results')); inp.setAttribute('aria-expanded', 'true'); inp.setAttribute('aria-activedescendant', cur.length ? 'sr-opt-0' : ''); @@ -143,6 +145,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto if (on) { e.scrollIntoView({ block: 'nearest' }); inp.setAttribute('aria-activedescendant', e.id); + fluidHoverKb(e); } }); diff --git a/public/js/ui.js b/public/js/ui.js index bb002b5..a1464e5 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,4 +1,4 @@ -import { iconChain } from '/js/icons.js?v=69c2b9bd'; +import { iconChain } from '/js/icons.js?v=04e7796e'; import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=a1b61636'; import { mk, @@ -13,7 +13,7 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=e8b2a9f7'; +} from '/js/utils.js?v=970a91b0'; 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=ab5fe77e'; @@ -581,7 +581,12 @@ export function buildMobile() { /* Every size below comes from this box, so the safe-area insets reach the layout as the space the stylesheet already reserved. Reading the insets - instead would take a value the platform has not reported yet. */ + instead would take a value the platform has not reported yet. + + Clear the width first. The grid is sized by --mgw, so a rebuild would + otherwise measure the width it was given last time rather than the space + it now has, and a reserve that grew would never be taken. */ + css(document.body, { '--mgw': '' }); const firstPage = mkPage(); strip.appendChild(firstPage.page); const gridBox = firstPage.grid.getBoundingClientRect(); diff --git a/public/js/utils.js b/public/js/utils.js index f18a8cf..f75d3c0 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,4 +1,4 @@ -import { iconChain } from '/js/icons.js?v=69c2b9bd'; +import { iconChain } from '/js/icons.js?v=04e7796e'; import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; import { SETTINGS_ICON } from '/js/settings-icon.js?v=b96e5b13'; import { BRAND_MARK } from '/js/brand-mark.js?v=1dcbf1ac'; diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index b9b2961..e36b481 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -4,8 +4,8 @@ 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=70ec6be1'; -import { renderColorControl } from '/js/admin-color-control.js?v=2d28867b'; +import { wireChecklist } from '/js/admin-shared.js?v=0f36d0dc'; +import { renderColorControl } from '/js/admin-color-control.js?v=d162431d'; import { seedCarried, applyOptionSet, @@ -13,9 +13,9 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=dcf7c37d'; +} from '/js/admin-logic.js?v=74cb4272'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; -import { qi } from '/js/utils.js?v=e8b2a9f7'; +import { qi } from '/js/utils.js?v=970a91b0'; const PE = ''; diff --git a/public/widgets/books/i18n/fa.json b/public/widgets/books/i18n/fa.json index d9c87ae..147ad68 100644 --- a/public/widgets/books/i18n/fa.json +++ b/public/widgets/books/i18n/fa.json @@ -7,15 +7,15 @@ "absUrl.label": "آدرس Audiobookshelf", "absUrl.placeholder": "http://host:13378", "absKey.label": "کلید API", - "absKey.hint": "Settings ← API Keys (نسخهٔ ۲.۲۶ به بعد)، یا توکن حساب شما.", + "absKey.hint": "Settings → API Keys (نسخهٔ 2.26 به بعد)، یا توکن حساب شما.", "komgaUrl.label": "آدرس Komga", "komgaUrl.placeholder": "http://host:25600", "komgaKey.label": "کلید API", - "komgaKey.hint": "Account Settings ← API Keys.", + "komgaKey.hint": "Account Settings → API Keys.", "kavitaUrl.label": "آدرس Kavita", "kavitaUrl.placeholder": "http://host:5000", "kavitaKey.label": "کلید API", - "kavitaKey.hint": "User Settings ← 3rd Party Clients.", + "kavitaKey.hint": "User Settings → 3rd Party Clients.", "shelves.label": "قفسه", "shelfName.label": "نام", "shelfName.placeholder": "اختیاری", diff --git a/public/widgets/connections/i18n/de.json b/public/widgets/connections/i18n/de.json index 5bda644..f83e44d 100644 --- a/public/widgets/connections/i18n/de.json +++ b/public/widgets/connections/i18n/de.json @@ -1,9 +1,9 @@ { "widget.label": "Verbindungen", - "view.map.label": "Map", + "view.map.label": "Karte", "view.vpn.label": "VPN", "view.label": "Ansicht", - "view.opt.map": "Map", + "view.opt.map": "Karte", "view.opt.vpn": "VPN", "vpn.label": "Verbindung", "vpn.service.label": "Dienst", diff --git a/public/widgets/connections/i18n/es.json b/public/widgets/connections/i18n/es.json index 9209ddc..2da613b 100644 --- a/public/widgets/connections/i18n/es.json +++ b/public/widgets/connections/i18n/es.json @@ -1,9 +1,9 @@ { "widget.label": "Conexiones", - "view.map.label": "Map", + "view.map.label": "Mapa", "view.vpn.label": "VPN", "view.label": "Vista", - "view.opt.map": "Map", + "view.opt.map": "Mapa", "view.opt.vpn": "VPN", "vpn.label": "Conexión", "vpn.service.label": "Servicio", diff --git a/public/widgets/connections/i18n/fa.json b/public/widgets/connections/i18n/fa.json index 5f5cfaa..96e7dbd 100644 --- a/public/widgets/connections/i18n/fa.json +++ b/public/widgets/connections/i18n/fa.json @@ -1,9 +1,9 @@ { "widget.label": "اتصال‌ها", - "view.map.label": "Map", + "view.map.label": "نقشه", "view.vpn.label": "VPN", "view.label": "نما", - "view.opt.map": "Map", + "view.opt.map": "نقشه", "view.opt.vpn": "VPN", "vpn.label": "اتصال", "vpn.service.label": "سرویس", diff --git a/public/widgets/connections/i18n/fr.json b/public/widgets/connections/i18n/fr.json index 4492afd..379307b 100644 --- a/public/widgets/connections/i18n/fr.json +++ b/public/widgets/connections/i18n/fr.json @@ -1,9 +1,9 @@ { "widget.label": "Connexions", - "view.map.label": "Map", + "view.map.label": "Carte", "view.vpn.label": "VPN", "view.label": "Vue", - "view.opt.map": "Map", + "view.opt.map": "Carte", "view.opt.vpn": "VPN", "vpn.label": "Connexion", "vpn.service.label": "Service", diff --git a/public/widgets/connections/i18n/zh-Hans.json b/public/widgets/connections/i18n/zh-Hans.json index 1d79fdc..d6639f6 100644 --- a/public/widgets/connections/i18n/zh-Hans.json +++ b/public/widgets/connections/i18n/zh-Hans.json @@ -1,9 +1,9 @@ { "widget.label": "连接", - "view.map.label": "Map", + "view.map.label": "地图", "view.vpn.label": "VPN", "view.label": "视图", - "view.opt.map": "Map", + "view.opt.map": "地图", "view.opt.vpn": "VPN", "vpn.label": "连接", "vpn.service.label": "服务", diff --git a/public/widgets/dashboard-switch/i18n/zh-Hans.json b/public/widgets/dashboard-switch/i18n/zh-Hans.json index bdd47b2..b39aece 100644 --- a/public/widgets/dashboard-switch/i18n/zh-Hans.json +++ b/public/widgets/dashboard-switch/i18n/zh-Hans.json @@ -3,7 +3,7 @@ "keychains.label": "钥匙串", "keychains.hint": "挂坠颜色保持为自动,即可根据面板地址自动选取。", "keychains.name.label": "名称", - "keychains.name.placeholder": "例如 客厅", + "keychains.name.placeholder": "例如客厅", "keychains.url.label": "面板地址", "keychains.url.placeholder": "http://host:port", "keychains.color.label": "挂坠颜色", diff --git a/public/widgets/disk-health/i18n/fa.json b/public/widgets/disk-health/i18n/fa.json index 57c1da1..b87c322 100644 --- a/public/widgets/disk-health/i18n/fa.json +++ b/public/widgets/disk-health/i18n/fa.json @@ -9,7 +9,7 @@ "scrutinyHref.placeholder": "http://your-server:8080", "truenasUrl.label": "آدرس TrueNAS", "truenasUrl.placeholder": "truenas.local", - "truenasKey.label": "کلید API ترونس", + "truenasKey.label": "کلید API TrueNAS", "truenasHref.label": "آدرس کلیک", "truenasHref.placeholder": "http://truenas.local", "bays.label": "محفظه‌ها", @@ -22,8 +22,8 @@ "ui.warning": "هشدار", "ui.failed": "خراب", "ui.ageAlert": "هشدار فرسودگی", - "ui.bay": "جایگاه {n}", - "ui.bayEmpty": "جایگاه {n}: خالی", + "ui.bay": "محفظه {n}", + "ui.bayEmpty": "محفظه {n}: خالی", "ui.noDrives": "هیچ درایوی گزارش نشد", "ui.notReporting": "گزارش نمی‌دهد" } diff --git a/public/widgets/dns/i18n/fa.json b/public/widgets/dns/i18n/fa.json index 9a8dcc5..39c92da 100644 --- a/public/widgets/dns/i18n/fa.json +++ b/public/widgets/dns/i18n/fa.json @@ -3,7 +3,7 @@ "provider.label": "ارائه‌دهنده", "dnsUrl.label": "آدرس سرور", "dnsUrl.placeholder": "dns-host:80", - "dnsUrl.hint": "نام کانتینر و پورت، یا یک آدرس کامل.", + "dnsUrl.hint": "نام کانتینر و درگاه، یا یک آدرس کامل.", "dnsHref.label": "آدرس کلیک", "dnsHref.hint": "جایی که با کلیک روی ویجت باز می‌شود. برای غیرفعال کردن خالی بگذارید.", "dnsUser.label": "نام کاربری", @@ -11,18 +11,18 @@ "dnsPiholePassword.label": "رمز عبور Pi-hole", "dnsPiholePassword.hint": "اگر Pi-hole شما رمز عبوری ندارد، خالی بگذارید.", "dnsTechnitiumToken.label": "توکن API", - "dnsTechnitiumToken.hint": "توکن API مربوط به Technitium (Administration ← Sessions ← Create Token).", + "dnsTechnitiumToken.hint": "توکن API مربوط به Technitium (Administration → Sessions → Create Token).", "dnsNextdnsApiKey.label": "کلید API", - "dnsNextdnsApiKey.hint": "از my.nextdns.io ← Account ← API.", - "dnsNextdnsProfile.label": "شناسه پروفایل", + "dnsNextdnsApiKey.hint": "از my.nextdns.io → Account → API.", + "dnsNextdnsProfile.label": "شناسهٔ پروفایل", "dnsNextdnsProfile.placeholder": "abc123", - "dnsNextdnsProfile.hint": "شناسه پروفایل یا پیکربندی (همان شناسه کوتاه در تنظیمات NextDNS شما).", + "dnsNextdnsProfile.hint": "شناسهٔ پروفایل یا پیکربندی (همان شناسهٔ کوتاه در تنظیمات NextDNS شما).", "ui.queriesBlocked": "درخواست‌های مسدودشده", "ui.aria": "پالایش DNS", "ui.allowed": "مجاز", - "ui.cached": "حافظه‌شده", + "ui.cached": "کش‌شده", "ui.blocked": "مسدود", - "ui.summary": "{pct}٪ از {total} درخواست مسدود شد. مجاز {allowed}، حافظه‌شده {cached}، مسدود {blocked}.", + "ui.summary": "{pct}٪ از {total} درخواست مسدود شد. مجاز {allowed}، کش‌شده {cached}، مسدود {blocked}.", "provider.opt.adguard": "AdGuard Home", "provider.opt.pihole": "Pi-hole", "provider.opt.technitium": "Technitium", diff --git a/public/widgets/github/i18n/fa.json b/public/widgets/github/i18n/fa.json index b731ce8..4257232 100644 --- a/public/widgets/github/i18n/fa.json +++ b/public/widgets/github/i18n/fa.json @@ -17,8 +17,8 @@ "githubPrFilters.opt.assigned": "واگذارشده", "githubPrFilters.opt.mentioned": "نام‌برده‌شده", "githubPrFilters.opt.review-requested": "درخواست بازبینی", - "ui.prAria": "درخواست‌های ادغام گیت‌هاب", + "ui.prAria": "درخواست‌های ادغام GitHub", "ui.noPrs": "درخواست ادغامی نیست", - "ui.contributionsAria": "مشارکت‌های گیت‌هاب: {total} در سال گذشته", - "ui.viewAll": "مشاهده همه" + "ui.contributionsAria": "مشارکت‌های GitHub: {total} در سال گذشته", + "ui.viewAll": "مشاهدهٔ همه" } diff --git a/public/widgets/nowplaying/i18n/fa.json b/public/widgets/nowplaying/i18n/fa.json index ede6ddc..189f888 100644 --- a/public/widgets/nowplaying/i18n/fa.json +++ b/public/widgets/nowplaying/i18n/fa.json @@ -7,14 +7,14 @@ "provider.opt.navidrome": "Navidrome", "plexUrl.label": "آدرس سرور Plex", "plexUrl.placeholder": "http://host:32400", - "plexUrl.hint": "سرور رسانهٔ Plex شما (درگاه پیش‌فرض ۳۲۴۰۰).", + "plexUrl.hint": "سرور رسانهٔ Plex شما (درگاه پیش‌فرض 32400).", "plexToken.label": "توکن Plex (X-Plex-Token)", "jellyfinUrl.label": "آدرس Jellyfin", "jellyfinUrl.placeholder": "http://host:8096", - "jellyfinKey.label": "کلید API جِلی‌فین", + "jellyfinKey.label": "کلید API Jellyfin", "embyUrl.label": "آدرس Emby", "embyUrl.placeholder": "http://host:8096", - "embyKey.label": "کلید API امبی", + "embyKey.label": "کلید API Emby", "navidromeUrl.label": "آدرس Navidrome", "navidromeUrl.placeholder": "http://host:4533", "navidromeUser.label": "نام کاربری", diff --git a/public/widgets/system-summary/i18n/fa.json b/public/widgets/system-summary/i18n/fa.json index 05bb12a..01f5e4f 100644 --- a/public/widgets/system-summary/i18n/fa.json +++ b/public/widgets/system-summary/i18n/fa.json @@ -15,12 +15,12 @@ "beszelUser.placeholder": "you@example.com", "beszelPass.label": "رمز عبور", "beszelSystem.label": "سیستم", - "beszelSystem.hint": "آدرس و حساب بالا را وارد کنید، سپس فهرست را بارگیری کنید.", + "beszelSystem.hint": "آدرس و حساب بالا را وارد کنید، سپس فهرست را بارگذاری کنید.", "unraidUrl.label": "آدرس Unraid", "unraidUrl.placeholder": "tower.local", - "unraidApiKey.label": "کلید API آنرید", + "unraidApiKey.label": "کلید API Unraid", "slots.label": "جایگاه", - "slots.type.label": "منبع", + "slots.type.label": "منبع سیستم", "slots.type.opt.cpu": "CPU", "slots.type.opt.ram": "RAM", "slots.type.opt.temp": "دما", @@ -42,7 +42,7 @@ "slots.share.hint": "برای کل آرایه خالی بگذارید.", "slots.thermalZone.label": "ناحیهٔ حرارتی", "slots.sensor.label": "حسگر", - "slots.sensor.hint": "آدرس بالا را وارد کنید، سپس فهرست را بارگیری کنید.", + "slots.sensor.hint": "آدرس بالا را وارد کنید، سپس فهرست را بارگذاری کنید.", "slots.color.label": "رنگ", "slots.beszelHint": "Beszel شمار فرایندها را گزارش نمی‌کند.", "slots.unraidHint": "Unraid زمان انتظار ورودی/خروجی و شمار فرایندها را گزارش نمی‌کند.", @@ -53,7 +53,7 @@ "network.mode.opt.throughput": "گذردهی", "network.mode.opt.uptime": "زمان کارکرد", "network.interface.label": "رابط شبکه", - "network.interface.hint": "اتصال بالا را کامل کنید، سپس فهرست را بارگیری کنید.", + "network.interface.hint": "اتصال بالا را کامل کنید، سپس فهرست را بارگذاری کنید.", "network.provider.label": "ارائه‌دهنده", "network.provider.opt.myspeed": "MySpeed", "network.provider.opt.speedtest-tracker": "Speedtest Tracker", diff --git a/public/widgets/weather/i18n/fa.json b/public/widgets/weather/i18n/fa.json index bb076e8..72a58ca 100644 --- a/public/widgets/weather/i18n/fa.json +++ b/public/widgets/weather/i18n/fa.json @@ -1,10 +1,10 @@ { "widget.label": "آب‌وهوا", - "cityQuery.label": "جست‌وجو", + "cityQuery.label": "جستجو", "cityQuery.placeholder": "مثلاً تهران", "city.label": "موقعیت", "city.hint": "نام شهری را بالا بنویسید، سپس دریافت را بزنید و مورد درست را انتخاب کنید.", - "units.label": "یکاها", + "units.label": "واحد", "units.opt.c": "°C", "units.opt.f": "°F", "feelsLike.label": "نمایش دمای محسوس",