diff --git a/astro.config.mjs b/astro.config.mjs index 6f57d75..a6a5cf3 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -27,34 +27,27 @@ export default defineConfig({ description: 'A calm, customizable dashboard for your services, designed to be useful without becoming another wall of data.', logo: { - light: './public/img/stackyard-wordmark-light.svg', - dark: './public/img/stackyard-wordmark-dark.svg', + light: './public/img/stackyard-wordmark-light-b3140d1e.svg', + dark: './public/img/stackyard-wordmark-dark-4680834d.svg', alt: '', replacesTitle: true, }, favicon: '/favicon.svg', - components: { Head: './src/components/Head.astro' }, - customCss: ['./src/styles/fonts.css', './src/styles/docs.css'], + components: { + Head: './src/components/Head.astro', + Sidebar: './src/components/Sidebar.astro', + }, + customCss: ['./src/styles/docs.css'], head: [ - { - tag: 'link', - attrs: { - rel: 'preload', - as: 'font', - type: 'font/woff2', - href: '/fonts/inter-latin.woff2', - crossorigin: true, - }, - }, { tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' } }, { tag: 'link', attrs: { rel: 'manifest', href: '/manifest.webmanifest' } }, { tag: 'meta', attrs: { name: 'theme-color', content: '#0d1117' } }, - { tag: 'meta', attrs: { property: 'og:image', content: `${SITE}/img/og-64722d2b.jpg` } }, + { tag: 'meta', attrs: { property: 'og:image', content: `${SITE}/img/og-48fdebbd.jpg` } }, { tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } }, { tag: 'meta', attrs: { property: 'og:image:height', content: '630' } }, { tag: 'meta', attrs: { property: 'og:site_name', content: 'Stackyard' } }, { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } }, - { tag: 'meta', attrs: { name: 'twitter:image', content: `${SITE}/img/og-64722d2b.jpg` } }, + { tag: 'meta', attrs: { name: 'twitter:image', content: `${SITE}/img/og-48fdebbd.jpg` } }, ], expressiveCode: { diff --git a/public/_headers b/public/_headers index e7e92a6..d76250d 100644 --- a/public/_headers +++ b/public/_headers @@ -8,9 +8,6 @@ /_astro/* Cache-Control: public, max-age=31536000, immutable -/fonts/* - Cache-Control: public, max-age=31536000, immutable - /img/* Cache-Control: public, max-age=31536000, immutable diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 1c10a51..20172d5 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-maskable.png b/public/favicon-maskable.png index 845f0e2..0c0b5c5 100644 Binary files a/public/favicon-maskable.png and b/public/favicon-maskable.png differ diff --git a/public/favicon.png b/public/favicon.png index 75b328c..ade5d29 100644 Binary files a/public/favicon.png and b/public/favicon.png differ diff --git a/public/favicon.svg b/public/favicon.svg index 38f2d04..5903e9d 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,4 +1,4 @@ - + diff --git a/public/fonts/inter-LICENSE.txt b/public/fonts/inter-LICENSE.txt deleted file mode 100644 index 9b2ca37..0000000 --- a/public/fonts/inter-LICENSE.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION AND CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/inter-latin.woff2 b/public/fonts/inter-latin.woff2 deleted file mode 100644 index e57a265..0000000 Binary files a/public/fonts/inter-latin.woff2 and /dev/null differ diff --git a/public/icons/authelia.svg b/public/icons/authelia.svg new file mode 100644 index 0000000..02b12b9 --- /dev/null +++ b/public/icons/authelia.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/authentik.svg b/public/icons/authentik.svg deleted file mode 100644 index 9933084..0000000 --- a/public/icons/authentik.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/icons/filebrowser-quantum.svg b/public/icons/filebrowser-quantum.svg new file mode 100644 index 0000000..04e878c --- /dev/null +++ b/public/icons/filebrowser-quantum.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/icons/immich.svg b/public/icons/immich.svg deleted file mode 100644 index 5c758ff..0000000 --- a/public/icons/immich.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/icons/jellyfin.svg b/public/icons/jellyfin.svg deleted file mode 100644 index c62f104..0000000 --- a/public/icons/jellyfin.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/icons/pi-hole.svg b/public/icons/pi-hole.svg deleted file mode 100644 index 5b023fc..0000000 --- a/public/icons/pi-hole.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/icons/portainer.svg b/public/icons/portainer.svg deleted file mode 100644 index 6e8805c..0000000 --- a/public/icons/portainer.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/public/icons/seerr-light.svg b/public/icons/seerr-light.svg new file mode 100644 index 0000000..0ab3668 --- /dev/null +++ b/public/icons/seerr-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/seerr.svg b/public/icons/seerr.svg deleted file mode 100644 index bf300a5..0000000 --- a/public/icons/seerr.svg +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/public/icons/settings-toggles.svg b/public/icons/settings-toggles.svg new file mode 100644 index 0000000..4213c1a --- /dev/null +++ b/public/icons/settings-toggles.svg @@ -0,0 +1 @@ + diff --git a/public/icons/settings.svg b/public/icons/settings.svg deleted file mode 100644 index c931fca..0000000 --- a/public/icons/settings.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/public/img/admin/app-edit-badge-03598133.png b/public/img/admin/app-edit-badge-03598133.png new file mode 100644 index 0000000..76fef8d Binary files /dev/null and b/public/img/admin/app-edit-badge-03598133.png differ diff --git a/public/img/admin/app-edit-badge-98d02665.png b/public/img/admin/app-edit-badge-98d02665.png deleted file mode 100644 index bcb6d0a..0000000 Binary files a/public/img/admin/app-edit-badge-98d02665.png and /dev/null differ diff --git a/public/img/admin/app-edit-badge-dark-adce9ae8.png b/public/img/admin/app-edit-badge-dark-adce9ae8.png new file mode 100644 index 0000000..6eb58f5 Binary files /dev/null and b/public/img/admin/app-edit-badge-dark-adce9ae8.png differ diff --git a/public/img/admin/app-edit-badge-dark-cee1d99a.png b/public/img/admin/app-edit-badge-dark-cee1d99a.png deleted file mode 100644 index 1ac71c6..0000000 Binary files a/public/img/admin/app-edit-badge-dark-cee1d99a.png and /dev/null differ diff --git a/public/img/admin/app-edit-basics-b3c6980e.png b/public/img/admin/app-edit-basics-b3c6980e.png deleted file mode 100644 index 025f523..0000000 Binary files a/public/img/admin/app-edit-basics-b3c6980e.png and /dev/null differ diff --git a/public/img/admin/app-edit-basics-dark-2910e8f5.png b/public/img/admin/app-edit-basics-dark-2910e8f5.png deleted file mode 100644 index dc95d36..0000000 Binary files a/public/img/admin/app-edit-basics-dark-2910e8f5.png and /dev/null differ diff --git a/public/img/admin/app-edit-labels-4eb0fb0b.png b/public/img/admin/app-edit-labels-4eb0fb0b.png new file mode 100644 index 0000000..efc4659 Binary files /dev/null and b/public/img/admin/app-edit-labels-4eb0fb0b.png differ diff --git a/public/img/admin/app-edit-labels-af3ec51a.png b/public/img/admin/app-edit-labels-af3ec51a.png deleted file mode 100644 index e944963..0000000 Binary files a/public/img/admin/app-edit-labels-af3ec51a.png and /dev/null differ diff --git a/public/img/admin/app-edit-labels-dark-07bd1535.png b/public/img/admin/app-edit-labels-dark-07bd1535.png new file mode 100644 index 0000000..195fc6f Binary files /dev/null and b/public/img/admin/app-edit-labels-dark-07bd1535.png differ diff --git a/public/img/admin/app-edit-labels-dark-2ff5328b.png b/public/img/admin/app-edit-labels-dark-2ff5328b.png deleted file mode 100644 index 12bc4a2..0000000 Binary files a/public/img/admin/app-edit-labels-dark-2ff5328b.png and /dev/null differ diff --git a/public/img/admin/appearance-31fd5463.png b/public/img/admin/appearance-31fd5463.png new file mode 100644 index 0000000..1bc096c Binary files /dev/null and b/public/img/admin/appearance-31fd5463.png differ diff --git a/public/img/admin/appearance-dark-449811e9.png b/public/img/admin/appearance-dark-449811e9.png deleted file mode 100644 index 75c205b..0000000 Binary files a/public/img/admin/appearance-dark-449811e9.png and /dev/null differ diff --git a/public/img/admin/appearance-dark-cb2e78c0.png b/public/img/admin/appearance-dark-cb2e78c0.png new file mode 100644 index 0000000..e179287 Binary files /dev/null and b/public/img/admin/appearance-dark-cb2e78c0.png differ diff --git a/public/img/admin/appearance-e9d99c15.png b/public/img/admin/appearance-e9d99c15.png deleted file mode 100644 index 280d1af..0000000 Binary files a/public/img/admin/appearance-e9d99c15.png and /dev/null differ diff --git a/public/img/admin/dashboard-apps-3dd6a50c.png b/public/img/admin/dashboard-apps-3dd6a50c.png deleted file mode 100644 index 5b1bd0e..0000000 Binary files a/public/img/admin/dashboard-apps-3dd6a50c.png and /dev/null differ diff --git a/public/img/admin/dashboard-apps-dark-34e8c0e1.png b/public/img/admin/dashboard-apps-dark-34e8c0e1.png deleted file mode 100644 index 1c8684f..0000000 Binary files a/public/img/admin/dashboard-apps-dark-34e8c0e1.png and /dev/null differ diff --git a/public/img/admin/dashboard-list-0d98d7b7.png b/public/img/admin/dashboard-list-0d98d7b7.png deleted file mode 100644 index 023b3ad..0000000 Binary files a/public/img/admin/dashboard-list-0d98d7b7.png and /dev/null differ diff --git a/public/img/admin/dashboard-list-dark-e87991d0.png b/public/img/admin/dashboard-list-dark-e87991d0.png deleted file mode 100644 index 2df11d9..0000000 Binary files a/public/img/admin/dashboard-list-dark-e87991d0.png and /dev/null differ diff --git a/public/img/admin/general-aeebf7aa.png b/public/img/admin/general-aeebf7aa.png deleted file mode 100644 index 7cf9ef1..0000000 Binary files a/public/img/admin/general-aeebf7aa.png and /dev/null differ diff --git a/public/img/dashboard-7d037291.jpg b/public/img/dashboard-7d037291.jpg new file mode 100644 index 0000000..3bd2c36 Binary files /dev/null and b/public/img/dashboard-7d037291.jpg differ diff --git a/public/img/dashboard-b3d50cc8.jpg b/public/img/dashboard-b3d50cc8.jpg deleted file mode 100644 index c1d2170..0000000 Binary files a/public/img/dashboard-b3d50cc8.jpg and /dev/null differ diff --git a/public/img/dashboard/badge-labels-c9fda069.png b/public/img/dashboard/badge-labels-c9fda069.png deleted file mode 100644 index a4fbb01..0000000 Binary files a/public/img/dashboard/badge-labels-c9fda069.png and /dev/null differ diff --git a/public/img/dashboard/badge-list-a6444739.png b/public/img/dashboard/badge-list-a6444739.png deleted file mode 100644 index 4d3e7ac..0000000 Binary files a/public/img/dashboard/badge-list-a6444739.png and /dev/null differ diff --git a/public/img/og-48fdebbd.jpg b/public/img/og-48fdebbd.jpg new file mode 100644 index 0000000..714169b Binary files /dev/null and b/public/img/og-48fdebbd.jpg differ diff --git a/public/img/og-64722d2b.jpg b/public/img/og-64722d2b.jpg deleted file mode 100644 index 8f50d38..0000000 Binary files a/public/img/og-64722d2b.jpg and /dev/null differ diff --git a/public/img/stackyard-mark-0ce25219.svg b/public/img/stackyard-mark-0ce25219.svg new file mode 100644 index 0000000..aa839f5 --- /dev/null +++ b/public/img/stackyard-mark-0ce25219.svg @@ -0,0 +1 @@ + diff --git a/public/img/stackyard-mark-light-c3aadde5.svg b/public/img/stackyard-mark-light-c3aadde5.svg new file mode 100644 index 0000000..635c805 --- /dev/null +++ b/public/img/stackyard-mark-light-c3aadde5.svg @@ -0,0 +1 @@ + diff --git a/public/img/stackyard-mark-light.svg b/public/img/stackyard-mark-light.svg deleted file mode 100644 index 7fa62b2..0000000 --- a/public/img/stackyard-mark-light.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/img/stackyard-mark.svg b/public/img/stackyard-mark.svg deleted file mode 100644 index 2e7fd7f..0000000 --- a/public/img/stackyard-mark.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/img/stackyard-wordmark-dark-4680834d.svg b/public/img/stackyard-wordmark-dark-4680834d.svg new file mode 100644 index 0000000..2fafe7f --- /dev/null +++ b/public/img/stackyard-wordmark-dark-4680834d.svg @@ -0,0 +1 @@ + diff --git a/public/img/stackyard-wordmark-dark.svg b/public/img/stackyard-wordmark-dark.svg deleted file mode 100644 index 33a4b53..0000000 --- a/public/img/stackyard-wordmark-dark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/img/stackyard-wordmark-light-b3140d1e.svg b/public/img/stackyard-wordmark-light-b3140d1e.svg new file mode 100644 index 0000000..a5c94c7 --- /dev/null +++ b/public/img/stackyard-wordmark-light-b3140d1e.svg @@ -0,0 +1 @@ + diff --git a/public/img/stackyard-wordmark-light.svg b/public/img/stackyard-wordmark-light.svg deleted file mode 100644 index b146d70..0000000 --- a/public/img/stackyard-wordmark-light.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index 43cd9ef..ff9eaeb 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,4 +1,4 @@ -import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=55685187'; +import { clr, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=eadafbcd'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c8c550c'; import { state } from '/js/admin-state.js?v=831e219e'; @@ -10,24 +10,24 @@ import { failureIsMissingApiPath, nextActiveIndex, sameIconName, -} from '/js/admin-logic.js?v=e3673bd7'; +} from '/js/admin-logic.js?v=cbb7417d'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { toast, - ag, - ap, + apiGet, + apiPost, PE_SVG, initInlineEdit, reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=52e149f5'; -import { createListbox } from '/js/listbox.js?v=32f787c3'; -import { MAX_LABELS } from '/js/badge-logic.js?v=ad283693'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=42c11a9b'; -import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; +} from '/js/admin-shared.js?v=d2e8b6dc'; +import { createListbox } from '/js/listbox.js?v=f057351d'; +import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=e963853a'; +import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=e4ea7820'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { iconSvg } from '/js/icon-set.js?v=08b74a28'; export function buildFolderForm(body, item) { const children = item?.children || []; @@ -208,7 +208,7 @@ export function buildAppForm(body, item) { /* The markup carries no style attribute, so the preview is painted here. */ const pv0 = el('ipv'); - if (pv0) pv0.style.background = rc(state.scol); + if (pv0) pv0.style.background = clr(state.scol); renderColorControl(el('icon-color-slot'), { value: state.scol || 'auto', idPrefix: 'icon-col', @@ -217,7 +217,7 @@ export function buildAppForm(body, item) { onChange(v) { state.scol = v; const pv = el('ipv'); - if (pv) pv.style.background = rc(state.scol); + if (pv) pv.style.background = clr(state.scol); }, }); renderColorControl(el('static-color-slot'), { @@ -621,7 +621,7 @@ function renderIconVariants(variants) { 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)}`); + const d = await apiGet(`/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. */ @@ -642,7 +642,7 @@ function wireIcon() { const mine = ++run; const q = v.replace(/\.(svg|png|ico)$/i, ''); try { - const d = await ag(`/api/icons/search?q=${encodeURIComponent(q)}`); + const d = await apiGet(`/api/icons/search?q=${encodeURIComponent(q)}`); if (mine !== run) return; showIPRes(d.results || [], v); } catch { @@ -827,7 +827,7 @@ function updPrev(explicit) { const p = el('ipv'); if (!p) return; const run = ++prevRun; - p.style.background = rc(state.scol); + p.style.background = clr(state.scol); if (!state.siurl) { setInitialGlyph(p); return; @@ -863,7 +863,7 @@ async function testPing() { st.textContent = t('app.testing'); const skipTls = inpById('f-skip-tls')?.checked || false; try { - const r = await ap('/api/ping', { url, skipTls }); + const r = await apiPost('/api/ping', { url, skipTls }); st.textContent = r.ok ? '✓ ' + t('app.reachable', { status: r.status }) : '✗ ' + t('app.httpError', { status: r.status }); @@ -920,21 +920,21 @@ function renderKvRows(host, rows, ph) { const defaultValuePlaceholder = ph => ph.split('=')[1] || 'value'; function kvRowEl(host, rows, row, ph) { - const el = document.createElement('div'); - el.className = 'kv-row'; + const rowEl = document.createElement('div'); + rowEl.className = 'kv-row'; const valPh = row.secret && row.valueSet && row.value === '' ? 'Configured' : defaultValuePlaceholder(ph); setHtml( - el, + rowEl, html` `, ); - const kEl = qi('.kv-k', el), - vEl = qi('.kv-v', el), - cEl = qi('.kv-cred input', el), - dEl = qSel('.kv-del', el); + const kEl = qi('.kv-k', rowEl), + vEl = qi('.kv-v', rowEl), + cEl = qi('.kv-cred input', rowEl), + dEl = qSel('.kv-del', rowEl); kEl.oninput = () => { row.key = kEl.value; }; @@ -959,7 +959,7 @@ function kvRowEl(host, rows, row, ph) { if (idx >= 0) rows.splice(idx, 1); renderKvRows(host, rows, ph); }; - return el; + return rowEl; } async function fetchBadge() { @@ -980,7 +980,7 @@ async function fetchBadge() { const params = serializeKvRows(state._bpar); const headers = serializeKvRows(state._bhdr); const skipTls = inpById('f-skip-tls')?.checked || false; - const r = await ap('/api/badge-proxy', { + const r = await apiPost('/api/badge-proxy', { url, params, headers, @@ -1007,7 +1007,7 @@ async function fetchBadge() { const tone = missingPath || advice.tone === TONE.WARN ? 'var(--warning)' : 'var(--danger)'; st.style.cssText = 'margin-top:4px;color:' + tone; if (missingPath) st.textContent = t('app.needsApiPathError'); - else st.textContent = advice.tone === TONE.WARN ? advice.message : '✗ ' + advice.message; + else st.textContent = (advice.tone === TONE.WARN ? '' : '✗ ') + t(advice.key, advice.vars); } if (advice.openAuth && !missingPath) { const authCb = inpById('auth-en'); diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index 1697b38..bf26807 100644 --- a/public/js/admin-auth.js +++ b/public/js/admin-auth.js @@ -1,11 +1,11 @@ -import { ag, ap } from '/js/admin-shared.js?v=52e149f5'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=d2e8b6dc'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; -import { el, inp as inpById, qa } from '/js/utils.js?v=55685187'; +import { el, inp as inpById, qa } from '/js/utils.js?v=eadafbcd'; export async function checkAuth(onLogin) { try { - const d = await ag('/api/auth/check'); + const d = await apiGet('/api/auth/check'); if (!d.enabled || d.authenticated) return true; showLoginScreen(onLogin); return false; @@ -39,7 +39,7 @@ function showLoginScreen(onLogin) { if (btn) btn.disabled = true; if (err) err.style.display = 'none'; try { - await ap('/api/auth/login', { password: pw?.value || '' }); + await apiPost('/api/auth/login', { password: pw?.value || '' }); if (s) s.style.display = 'none'; onLogin?.(); } catch (e) { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index 690d436..18a7e73 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,8 +1,8 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=52e149f5'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=d2e8b6dc'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=55685187'; -import { iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { qa, q } from '/js/utils.js?v=eadafbcd'; +import { iconSvg } from '/js/icon-set.js?v=08b74a28'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; @@ -158,8 +158,8 @@ export function renderColorControl( const rainbow = html``; const swatches = variant ? html`${top.map(kwSwatch)}${rainbow}` : html`${rainbow}${swatchColors.map(swatch)}`; const wrap = document.createElement('div'); - const slider = (label, cls, id, max, val, lo, hi) => html` -
${label}
${raw(lo)}${raw(hi)}
`; + const slider = (name, cls, id, max, val, lo, hi) => html` +
${name}
${raw(lo)}${raw(hi)}
`; const hueRow = hues.length ? html`
${t('color.system')}
${hues.map(kwSwatch)}
` : ''; @@ -228,11 +228,6 @@ export function renderColorControl( target on a phone. */ sEl.style.backgroundImage = `linear-gradient(var(--slider-dir), ${_hsvToHex(h, 0, v)}, ${_hsvToHex(h, 100, v)})`; vEl.style.backgroundImage = `linear-gradient(var(--slider-dir), #000, ${_hsvToHex(h, 100, 100)})`; - /* Each knob previews what its own slider is set to: the hue on its own, the - resulting colour for the other two. */ - hEl.style.setProperty('--knob-fill', _hsvToHex(h, 100, 100)); - sEl.style.setProperty('--knob-fill', hex); - vEl.style.setProperty('--knob-fill', hex); qa('.cc-swatch', container).forEach(b => { let on = false; if (mode !== 'color') on = b.dataset.v === mode; @@ -288,12 +283,12 @@ export function renderColorControl( placeholder: '#rrggbb or any CSS color', onCommit(val) { if (mode !== 'color' && val === kwName(mode)) return; - const { value, ok } = normalizeColorInput(val); - const hv = ok ? _hexToHsv(value) : null; + const { value: parsed, ok } = normalizeColorInput(val); + const hv = ok ? _hexToHsv(parsed) : null; if (hv) { mode = 'color'; showTune = true; - setSliders(value); + setSliders(parsed); } else if (val) toast(t('toast.colorInvalid'), 'err'); commit(); }, diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js index 44b450c..b624a1a 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=831e219e'; -import { snapshotItems } from '/js/admin-save-logic.js?v=858f3f84'; -import { qa } from '/js/utils.js?v=55685187'; +import { snapshotItems } from '/js/admin-save-logic.js?v=60a82419'; +import { qa } from '/js/utils.js?v=eadafbcd'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/public/js/admin-error.js b/public/js/admin-error.js index c821a88..f12a64b 100644 --- a/public/js/admin-error.js +++ b/public/js/admin-error.js @@ -1,5 +1,9 @@ -/* Maps the API's `kind` to what the admin UI should do about it. Keep it free - of the DOM and of imports: api/test loads it directly. */ +/* Maps the API's structured error to what the admin UI should do about it. Keep + it free of the DOM and of imports: its tests load it directly. + + The API's `code` is the whole input. `error` is prose the server wrote for a + log; it is never translated and never shown. Advice therefore carries a + translation key and its variables, never finished text. */ export const KIND = Object.freeze({ NETWORK: 'network', @@ -13,80 +17,71 @@ export const KIND = Object.freeze({ export const TONE = Object.freeze({ WARN: 'warn', ERROR: 'error' }); -/* The reason code the API sends with a block that ALLOW_PRIVATE_IPS would let - through. The message itself never names the address. */ -const PRIVATE_ADDRESS = 'private-address'; +const BY_CODE = Object.freeze({ + 'blocked.private-address': 'adminError.privateAddress', + 'invalid.retype': 'adminError.retype', + 'upstream.redirect': 'adminError.redirect', + 'network.tls-ignored': 'adminError.tlsIgnored', + 'network.tls-untrusted': 'adminError.tlsUntrusted', +}); -const PRIVATE_ADDRESS_ADVICE = - 'Most homelab services live on private IPs. Set ALLOW_PRIVATE_IPS=true and restart the container.'; +const BY_KIND = Object.freeze({ + [KIND.NETWORK]: 'adminError.unreachable', + [KIND.TIMEOUT]: 'adminError.unreachable', + [KIND.BLOCKED]: 'adminError.genericBlocked', + [KIND.AUTH]: 'adminError.sessionExpired', + [KIND.UPSTREAM]: 'adminError.genericUpstream', + [KIND.INVALID]: 'adminError.genericInvalid', + [KIND.INTERNAL]: 'adminError.genericInternal', +}); -const blockedForPrivateAddress = ({ kind, detail }) => kind === KIND.BLOCKED && detail?.reason === PRIVATE_ADDRESS; +/* Each key is a whole sentence. Composing one from a fragment and a number puts + word order in the code and breaks every language that does not share it. */ +function statusKey(status) { + if (status === 404) return 'adminError.statusNotFound'; + if (status === 405) return 'adminError.statusMethod'; + if (status === 407) return 'adminError.statusProxyAuth'; + if (status >= 500) return 'adminError.statusServer'; + return 'adminError.statusOther'; +} -/* An unknown or missing kind degrades to INTERNAL. */ export function readError(e) { const kind = e && typeof e.kind === 'string' && Object.values(KIND).includes(e.kind) ? e.kind : KIND.INTERNAL; const detail = e && e.detail && typeof e.detail === 'object' ? e.detail : null; - return { kind, detail, message: (e && e.message) || '' }; + const code = e && typeof e.code === 'string' && e.code ? e.code : kind; + return { kind, code, detail }; +} + +function adviceFor(read) { + const { kind, code, detail } = read; + const status = detail && typeof detail.status === 'number' ? detail.status : null; + if (code === 'upstream.status' && status !== null) return { key: statusKey(status), vars: { status } }; + if (BY_CODE[code]) { + return status !== null ? { key: BY_CODE[code], vars: { status } } : { key: BY_CODE[code] }; + } + return { key: BY_KIND[kind] || BY_KIND[KIND.INTERNAL] }; } export function badgeErrorAdvice(e) { const read = readError(e); - const { kind, detail, message } = read; + const { kind, code, detail } = read; + const status = detail && typeof detail.status === 'number' ? detail.status : null; if (kind === KIND.AUTH) { - return { - tone: TONE.ERROR, - message: 'Your session has expired. Sign in again to continue.', - openAuth: false, - sessionExpired: true, - }; + return { tone: TONE.ERROR, code, key: 'adminError.sessionExpired', openAuth: false, sessionExpired: true }; } - if (kind === KIND.UPSTREAM && (detail?.status === 401 || detail?.status === 403)) { - return { - tone: TONE.WARN, - message: 'Authentication required. Enable the Authentication toggle below and add your API key.', - openAuth: true, - sessionExpired: false, - }; + if (kind === KIND.UPSTREAM && (status === 401 || status === 403)) { + return { tone: TONE.WARN, code, key: 'adminError.authRequired', openAuth: true, sessionExpired: false }; } - if (kind === KIND.NETWORK || kind === KIND.TIMEOUT) { - return { - tone: TONE.WARN, - message: - "Can't reach this address from Docker. Try using the container name, e.g. http://container-name:8181/api/v2", - openAuth: false, - sessionExpired: false, - }; - } - - if (blockedForPrivateAddress(read)) { - return { - tone: TONE.WARN, - message: `${message} ${PRIVATE_ADDRESS_ADVICE}`, - openAuth: false, - sessionExpired: false, - }; - } - - /* BLOCKED carries a reason this project wrote, so it is shown verbatim. */ - return { - tone: TONE.ERROR, - message: message || 'Request failed.', - openAuth: false, - sessionExpired: false, - }; + const warn = kind === KIND.NETWORK || kind === KIND.TIMEOUT || code === 'blocked.private-address'; + return { ...adviceFor(read), code, tone: warn ? TONE.WARN : TONE.ERROR, openAuth: false, sessionExpired: false }; } -/* Same shape as badgeErrorAdvice: a settings Fetch and a badge test report the - same failures and must not disagree about the tone. */ +/* Same wording as badgeErrorAdvice: a settings Fetch and a badge test report the + same failures and must not disagree about either the text or the tone. */ export function optionsErrorAdvice(e) { - const read = readError(e); - const { kind, message } = read; - if (blockedForPrivateAddress(read)) { - return { tone: TONE.WARN, message: `${message} ${PRIVATE_ADDRESS_ADVICE}` }; - } - if (kind === KIND.INVALID && message) return { tone: TONE.ERROR, message }; - return { tone: TONE.ERROR, message: 'Fetch failed: ' + (message || 'Request failed.') }; + const { tone, key, vars, code } = badgeErrorAdvice(e); + return vars ? { tone, code, key, vars } : { tone, code, key }; } diff --git a/public/js/admin-list.js b/public/js/admin-list.js index d33d7d8..9b7e806 100644 --- a/public/js/admin-list.js +++ b/public/js/admin-list.js @@ -12,16 +12,16 @@ reads and writes them too. */ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=831e219e'; -import { snapshotItems } from '/js/admin-save-logic.js?v=858f3f84'; -import { reorderItems } from '/js/admin-logic.js?v=e3673bd7'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=a9bbc794'; -import { paintIcon } from '/js/admin-shared.js?v=52e149f5'; -import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=55685187'; +import { snapshotItems } from '/js/admin-save-logic.js?v=60a82419'; +import { reorderItems } from '/js/admin-logic.js?v=cbb7417d'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=36006b2b'; +import { paintIcon } from '/js/admin-shared.js?v=d2e8b6dc'; +import { clr, el, qa, setUserText } from '/js/utils.js?v=eadafbcd'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=1f1ea9c1'; -import { sizeLabel } from '/js/admin-widget-form.js?v=17a37df4'; -import { widgetGlyph } from '/js/widget-glyphs.js?v=3adb57ad'; -import { iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { sizeLabel } from '/js/admin-widget-form.js?v=fd3dcc6c'; +import { widgetGlyph } from '/js/widget-glyphs.js?v=e1d968ad'; +import { iconSvg } from '/js/icon-set.js?v=08b74a28'; /** @type {{ openModal: (idx: number|null) => void, openFolderPicker: (appId: string|null, targetFolderId?: string|null) => void, @@ -48,9 +48,9 @@ const SIZE_ICONS = { xlarge: iconSvg('xlarge', 26), }; function svgNode(markup) { - const t = document.createElement('template'); - setHtml(t, raw(markup)); - return t.content.firstElementChild; + const tpl = document.createElement('template'); + setHtml(tpl, raw(markup)); + return tpl.content.firstElementChild; } function moveRow(item, dir, opts = {}) { @@ -94,7 +94,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n if (_filtering) handle.style.visibility = 'hidden'; const ico = document.createElement('div'); ico.className = 'rico'; - ico.style.background = rc(item.color); + ico.style.background = clr(item.color); if (item.type === 'folder') { ico.appendChild(svgNode(FOLDER_ICON)); } else if (item.type === 'widget') { diff --git a/public/js/admin-logic.js b/public/js/admin-logic.js index 626d7bf..f183948 100644 --- a/public/js/admin-logic.js +++ b/public/js/admin-logic.js @@ -303,15 +303,20 @@ export function isBareHostUrl(url) { } /** Whether a failed badge fetch should report the missing API path instead of - the failure itself. A session that has expired is reported as itself: the - address is not why the request failed. + the failure itself. + + Shown only where a missing path explains the failure: a bare address that + answered 404 or redirected, usually to a login page. Anything else is + reported as itself. @param {string} url - @param {{ sessionExpired?: boolean }} advice + @param {{ sessionExpired?: boolean, code?: string, vars?: { status?: number } }} advice @returns {boolean} */ export function failureIsMissingApiPath(url, advice) { - if (advice && advice.sessionExpired) return false; - return isBareHostUrl(url); + if (!advice || advice.sessionExpired) return false; + if (!isBareHostUrl(url)) return false; + if (advice.code === 'upstream.status') return advice.vars?.status === 404; + return advice.code === 'upstream.redirect'; } /** Compares what `read` returns now with what it returned at the last reset. diff --git a/public/js/admin-save-logic.js b/public/js/admin-save-logic.js index 8e13495..6ac88ae 100644 --- a/public/js/admin-save-logic.js +++ b/public/js/admin-save-logic.js @@ -113,8 +113,9 @@ export function buildActivityLabel(path, style) { /** @param {any} v @param {any} [orig] @param {Iterable} [takenIds] */ export function buildAppItem(v, orig, takenIds = []) { - if (!v.label) return { error: 'Name required' }; - if (!v.href) return { error: 'URL required' }; + /* A key, not a sentence. This module stays free of imports. */ + if (!v.label) return { errorKey: 'toast.nameRequired' }; + if (!v.href) return { errorKey: 'toast.urlRequired' }; const DEFCOL = '#0289ff'; /* One badges any count above zero, which is the default, so it is not stored. */ const custMin = Number.isFinite(v.custMin) && v.custMin > 1 ? Math.floor(v.custMin) : undefined; diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index d3fed7b..548323a 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, ag, ap, reveal, swapContent } from '/js/admin-shared.js?v=52e149f5'; +import { toast, apiGet, apiPost, reveal, swapContent } from '/js/admin-shared.js?v=d2e8b6dc'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { @@ -7,9 +7,11 @@ import { clearsStoredPassword, createDirtyTracker, BLOCK, -} from '/js/admin-logic.js?v=e3673bd7'; +} from '/js/admin-logic.js?v=cbb7417d'; import { confirmText } from '/js/modal.js?v=11fa1eff'; -import { el, inp, setUserText } from '/js/utils.js?v=55685187'; +import { el, inp, setUserText } from '/js/utils.js?v=eadafbcd'; +import { renderColorControl } from '/js/admin-color-control.js?v=e963853a'; +import { BACKDROP } from '/js/background.js?v=175d9a7f'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ let _passwordSet = false; @@ -52,7 +54,7 @@ const readWallpaperForm = () => _val('bg-col-inp', 'bg-col'), _val('bg-url-inp', 'bg-url'), _val('bg-fit'), - _val('bg-color-inp', 'bg-color'), + _val('bg-color-val'), _val('bg-apikey-inp', 'bg-apikey'), ]); @@ -110,6 +112,15 @@ function syncSessionRows(now = false) { reveal(el('revoke-tip-wrap'), canRevoke, now); } +/* Rebuild on every load. The control takes its value at render time and has no + setter, so reusing one shows a stale colour and saves it. */ +function renderBgColor(value) { + const slot = el('bg-color-slot'); + if (!slot) return; + slot.textContent = ''; + renderColorControl(slot, { value, idPrefix: 'bg-color', label: t('appearance.color') }); +} + export function loadSettings(c) { const s = c.settings || {}; const ld = inp('set-lbl-d'); @@ -141,7 +152,7 @@ export function loadSettings(c) { const apiEl = inp('bg-apikey-inp') || inp('bg-apikey'); if (apiEl) { apiEl.placeholder = '●●●●●●●●●● (configured)'; - ag('/api/settings/unsplash-key') + apiGet('/api/settings/unsplash-key') .then(d => { const vEl = el('ie-apikey-v'); if (!d.configured) { @@ -157,19 +168,17 @@ export function loadSettings(c) { if (colEl) colEl.value = bg.collection || ''; const urlEl = inp('bg-url'); if (urlEl) urlEl.value = bg.url || ''; - const colorEl = inp('bg-color'); - if (colorEl) colorEl.value = bg.color || ''; const brEl = inp('bg-br'); const brVal = el('bg-br-val'); - function updateSliderFill(el) { - if (!el) return; - const min = parseFloat(el.min) || 0.1, - max = parseFloat(el.max) || 1.0; - const pct = ((parseFloat(el.value) - min) / (max - min)) * 100; + function updateSliderFill(slider) { + if (!slider) return; + const min = parseFloat(slider.min) || 0.1, + max = parseFloat(slider.max) || 1.0; + const pct = ((parseFloat(slider.value) - min) / (max - min)) * 100; /* backgroundImage, never the background shorthand. The shorthand resets background-clip, which is what keeps the track thin inside the 44px touch target on a phone. */ - el.style.backgroundImage = `linear-gradient(var(--slider-dir), var(--ac) 0%, var(--ac) ${pct}%, var(--bd-inner) ${pct}%, var(--bd-inner) 100%)`; + slider.style.backgroundImage = `linear-gradient(var(--slider-dir), var(--ac) 0%, var(--ac) ${pct}%, var(--bd-inner) ${pct}%, var(--bd-inner) 100%)`; } if (brEl) { brEl.value = bg.brightness ?? 0.62; @@ -207,9 +216,8 @@ export function loadSettings(c) { _si('bg-url-inp', s.background?.url || ''); _si('bg-fit', s.background?.fit === 'fit' ? 'fit' : 'fill'); showWallpaperFile(s.background?.url || ''); - _si('bg-color-inp', s.background?.color || ''); + renderBgColor(s.background?.color || BACKDROP); _sv('ie-bgurl-v', s.background?.url, 'Image URL'); - _sv('ie-bgcolor-v', s.background?.color, '#rrggbb or any CSS color'); const ipEl = inp('srv-ip'); if (ipEl) ipEl.value = s.server?.hostIp || ''; @@ -235,7 +243,7 @@ export function loadSettings(c) { const secLogout = el('sec-logout'); const secRevoke = inp('sec-revoke'); secLogout?.addEventListener('click', async () => { - await ap('/api/auth/logout', {}).catch(() => {}); + await apiPost('/api/auth/logout', {}).catch(() => {}); location.reload(); }); secRevoke?.addEventListener('click', async () => { @@ -249,7 +257,7 @@ export function loadSettings(c) { if (!ok) return; secRevoke.disabled = true; try { - await ap('/api/auth/revoke-sessions', {}); + await apiPost('/api/auth/revoke-sessions', {}); toast(t('toast.sessionsRevoked'), 'ok'); } catch (e) { toast(e.message || t('toast.saveFailed'), 'err'); @@ -267,7 +275,7 @@ export function loadSettings(c) { async function syncAuthFromServer() { let d; try { - d = await ag('/api/auth/check'); + d = await apiGet('/api/auth/check'); } catch { return; } @@ -303,12 +311,14 @@ export function showWallpaperFile(url) { export function showBgFields(type) { const host = el('bg-unsplash-fields')?.parentElement; swapContent(host, () => { - ['unsplash', 'url', 'color'].forEach(t => { - const node = el(`bg-${t}-fields`); - if (node) node.classList.toggle('d-none', t !== type); + ['unsplash', 'url', 'color'].forEach(kind => { + const node = el(`bg-${kind}-fields`); + if (node) node.classList.toggle('d-none', kind !== type); }); const brRow = el('bg-brightness-row'); if (brRow) brRow.classList.toggle('d-none', type === 'color'); + el('bgcol-hint')?.classList.toggle('d-none', type !== 'unsplash'); + el('bg-url-hint')?.classList.toggle('d-none', type !== 'url'); }); } /** @param {Event} [e] */ @@ -316,10 +326,10 @@ async function saveLabels(e) { const toggled = /** @type {HTMLInputElement|null} */ (e?.target ?? null); const wasChecked = toggled ? toggled.checked : false; try { - const c = await ag('/api/config'); + const c = await apiGet('/api/config'); c.settings = c.settings || {}; c.settings.showLabels = { desktop: inp('set-lbl-d')?.checked !== false, ios: inp('set-lbl-m')?.checked || false }; - await ap('/api/config', c); + await apiPost('/api/config', c); toast(t('toast.saved')); } catch (err) { /* Put the box back on a failure, or it shows a setting the server was never @@ -332,10 +342,10 @@ async function saveKeepAwake(e) { const toggled = /** @type {HTMLInputElement|null} */ (e?.target ?? null); const wasChecked = toggled ? toggled.checked : false; try { - const c = await ag('/api/config'); + const c = await apiGet('/api/config'); c.settings = c.settings || {}; c.settings.keepAwake = !!inp('set-awake')?.checked; - await ap('/api/config', c); + await apiPost('/api/config', c); toast(t('toast.saved')); } catch (err) { /* Put the box back on a failure, or it shows a setting the server was never @@ -355,17 +365,17 @@ async function saveWallpaper() { bg.url = (inp('bg-url-inp') || inp('bg-url'))?.value?.trim() || ''; bg.fit = inp('bg-fit')?.value === 'fit' ? 'fit' : 'fill'; } else if (type === 'color') { - bg.color = (inp('bg-color-inp') || inp('bg-color'))?.value?.trim() || ''; + bg.color = inp('bg-color-val')?.value?.trim() || ''; } - const c = await ag('/api/config'); + const c = await apiGet('/api/config'); c.settings = c.settings || {}; c.settings.background = bg; - await ap('/api/config', c); + await apiPost('/api/config', c); /* 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 ap('/api/settings/unsplash-key', { apiKey: keyVal }); + if (keyVal) await apiPost('/api/settings/unsplash-key', { apiKey: keyVal }); } _bgTrack?.reset(); toast(t('toast.saved')); @@ -401,7 +411,7 @@ async function saveServer() { } let probe; try { - probe = await ap('/api/docker/test', { url }); + probe = await apiPost('/api/docker/test', { url }); } catch (e) { toast(t('toast.saveFailed', { err: e.message }), 'err'); return; @@ -436,7 +446,7 @@ async function saveServer() { } try { - const c = await ag('/api/config'); + const c = await apiGet('/api/config'); c.settings = c.settings || {}; const prevLang = c.settings.language || 'en'; const dockerEnabled = inp('srv-docker-en')?.checked || false; @@ -451,17 +461,17 @@ async function saveServer() { c.settings.language = inp('lang-sel')?.value || 'en'; const langChanged = c.settings.language !== prevLang; - await ap('/api/config', c); + await apiPost('/api/config', c); if (shouldWritePassword({ enabled, newPassword: pw })) { - await ap('/api/auth/set-password', { password: pw }); + await apiPost('/api/auth/set-password', { password: pw }); const pwEl = inp('sec-pw'); if (pwEl) { pwEl.value = ''; pwEl.placeholder = '●●●●●●●●●● (configured)'; } } - await ap('/api/auth/toggle', { enabled }); + await apiPost('/api/auth/toggle', { enabled }); if (!enabled) { const pwEl = inp('sec-pw'); if (pwEl) { diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index 6643998..32fc5f0 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,20 +1,20 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=e3673bd7'; -import { el, q } from '/js/utils.js?v=55685187'; +import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=cbb7417d'; +import { el, q } from '/js/utils.js?v=eadafbcd'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { iconChain } from '/js/icons.js?v=9c8c550c'; -import { iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { iconSvg } from '/js/icon-set.js?v=08b74a28'; export const API = ''; let tt; let _toastWired = false; -/** @param {string} m @param {'ok'|'err'} [t] @returns {void} */ -export const toast = (m, t = 'ok') => { +/** @param {string} m @param {'ok'|'err'} [tone] @returns {void} */ +export const toast = (m, tone = 'ok') => { const e = el('toast'); e.textContent = m; - e.className = `show ${t}`; + e.className = `show ${tone}`; clearTimeout(tt); if (!_toastWired) { _toastWired = true; @@ -33,20 +33,23 @@ export const toast = (m, t = 'ok') => { e.className = ''; }); } - const ms = toastHoldMs(t, m, 'show'); + const ms = toastHoldMs(tone, m, 'show'); if (ms != null) tt = setTimeout(() => (e.className = ''), ms); }; -/* Carry `kind` and `detail`, so callers branch on data, never on message - text. */ +/* Carry `kind`, `code` and `detail`, so callers branch on data, never on + message text. Dropping a field here fails silently: the advice falls back to + the kind and the screen says something true but never the specific sentence. */ /** An error carrying the API's structured fields. - @typedef {Error & { status?: number, kind?: string, detail?: Record }} ApiError */ + @typedef {Error & { status?: number, kind?: string, code?: string, + detail?: Record }} ApiError */ /** @param {number} status @param {any} body @returns {ApiError} */ function tagged(status, body) { const e = /** @type {ApiError} */ (new Error((body && body.error) || 'HTTP ' + status)); e.status = status; if (body && typeof body.kind === 'string') e.kind = body.kind; + if (body && typeof body.code === 'string') e.code = body.code; if (body && body.detail && typeof body.detail === 'object') e.detail = body.detail; return e; } @@ -75,22 +78,22 @@ function reauthenticate() { /* Retried once only. A second 401 after a successful sign-in is the server refusing the request itself. */ -export const ag = async (p, recover = true) => { +export const apiGet = async (p, recover = true) => { const r = await fetch(API + p, { cache: 'no-store' }); - if (recover && recoversSession(p, r.status) && (await reauthenticate())) return ag(p, false); + if (recover && recoversSession(p, r.status) && (await reauthenticate())) return apiGet(p, false); if (!r.ok) { const d = r.status === 401 ? null : await r.json().catch(() => null); throw tagged(r.status, d || (r.status === 401 ? { error: 'Unauthorised', kind: 'auth' } : null)); } return r.json(); }; -export const ap = async (p, b, recover = true) => { +export const apiPost = async (p, b, recover = true) => { const r = await fetch(API + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b), }); - if (recover && recoversSession(p, r.status) && (await reauthenticate())) return ap(p, b, false); + if (recover && recoversSession(p, r.status) && (await reauthenticate())) return apiPost(p, b, false); if (!r.ok) { const d = await r.json().catch(() => null); throw tagged(r.status, d || (r.status === 401 ? { error: 'Unauthorised', kind: 'auth' } : null)); diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 6087cf5..efc1580 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,12 +1,12 @@ import { state } from '/js/admin-state.js?v=831e219e'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=52e149f5'; -import { createListbox } from '/js/listbox.js?v=32f787c3'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=45f9a04b'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=d2e8b6dc'; +import { createListbox } from '/js/listbox.js?v=f057351d'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=73604add'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=e3673bd7'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=cbb7417d'; import { t } from '/js/i18n.js?v=1f1ea9c1'; -import { q, qi, qa } from '/js/utils.js?v=55685187'; -import { iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { q, qi, qa } from '/js/utils.js?v=eadafbcd'; +import { iconSvg } from '/js/icon-set.js?v=08b74a28'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/public/js/admin.js b/public/js/admin.js index db66105..564171f 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=442d8e7d'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9efdedbe'; -import { initList, render, syncFilterUI } from '/js/admin-list.js?v=15bd26cd'; -import { resolveAdminSection } from '/js/admin-logic.js?v=e3673bd7'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=58e2b685'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=2bd61e6c'; +import { initList, render, syncFilterUI } from '/js/admin-list.js?v=4e82ea4a'; +import { resolveAdminSection } from '/js/admin-logic.js?v=cbb7417d'; import { buildAppItem, claimFolderChildren, @@ -9,18 +9,26 @@ import { saveWithRevert, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=858f3f84'; -import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=4aed22e2'; -import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=52e149f5'; +} from '/js/admin-save-logic.js?v=60a82419'; +import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=23cfe19f'; +import { + apiGet, + apiPost, + initInlineEdit, + paintIcon, + reveal, + setReauthHandler, + toast, +} from '/js/admin-shared.js?v=d2e8b6dc'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=831e219e'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=17a37df4'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=fd3dcc6c'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=32f787c3'; +import { createListbox } from '/js/listbox.js?v=f057351d'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=1f1ea9c1'; import { loadLocalIcons } from '/js/icons.js?v=9c8c550c'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=08b74a28'; import { clearSkipTls, convert, @@ -29,7 +37,7 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=d154ca56'; +} from '/js/import-foreign.js?v=f9c0a120'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff'; import { @@ -40,11 +48,10 @@ import { resolveTheme, watchSystemTheme, writeMode, -} from '/js/theme.js?v=787bfdff'; -import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=55685187'; -import { normalizeColorInput } from '/js/admin-color-control.js?v=42c11a9b'; +} from '/js/theme.js?v=eeafa4b5'; +import { el, inp, q, qa, clr, setUserText, tgt } from '/js/utils.js?v=eadafbcd'; +import { applyBackground, resolveBackground } from '/js/background.js?v=175d9a7f'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c'; -import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; ensureSprite(); @@ -63,7 +70,7 @@ onLayoutChange(_syncMobile, _mobileAtLoad); async function load() { await loadLocalIcons(); - const c = await ag('/api/config'); + const c = await apiGet('/api/config'); state.items = c.items || []; state._settings = c.settings || {}; await initI18n(c.settings?.language || 'en'); @@ -71,7 +78,7 @@ async function load() { initVersion(); syncPickerLabels(); try { - const wr = await ag('/api/widgets'); + const wr = await apiGet('/api/widgets'); state._widgetReg = Object.create(null); (wr.widgets || []).forEach(w => { state._widgetReg[w.name] = w; @@ -92,40 +99,13 @@ async function load() { } async function applyBg() { - const root = document.documentElement; + const s = (state._settings && state._settings.background) || {}; + let bg = null; try { - const bg = (state._settings && state._settings.background) || {}; - if (bg.type === 'color' && bg.color) { - root.style.setProperty('--bg-image', 'none'); - root.style.setProperty('--bg-color', String(bg.color).replace(/[^a-zA-Z0-9#(),.\s%]/g, '')); - root.style.setProperty('--bg-brightness', '1'); - root.style.setProperty('--bg-size', 'cover'); - } else if (bg.type === 'url' && bg.url) { - root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(bg.url)}')`); - root.style.setProperty('--bg-color', '#0d1117'); - root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); - root.style.setProperty('--bg-size', bg.fit === 'fit' ? 'contain' : 'cover'); - } else if (bg.type === 'unsplash') { - let url = loadWallpaper(bg); - if (!url) { - const r = await fetch('/api/wallpaper', { cache: 'no-store' }); - const d = await r.json(); - url = d.url || null; - if (url) saveWallpaper(url, bg); - } - if (url) { - const shown = url; - const img = new Image(); - img.onload = () => { - root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(shown)}')`); - root.style.setProperty('--bg-color', '#0d1117'); - root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); - root.style.setProperty('--bg-size', 'cover'); - }; - img.src = shown; - } - } + bg = await resolveBackground(s); } catch {} + if (bg) applyBackground(document.documentElement, bg); + else if (s.type === 'unsplash') toast(t('toast.wallpaperUnavailable'), 'err'); } /** Returns whether the write reached the server. */ async function save() { @@ -133,9 +113,9 @@ async function save() { state.saving = true; let ok = false; try { - const full = await ag('/api/config'); + const full = await apiGet('/api/config'); full.items = state.items; - await ap('/api/config', full); + await apiPost('/api/config', full); _savedItems = JSON.stringify(state.items); toast(t('toast.saved')); ok = true; @@ -157,14 +137,14 @@ async function appendAndSave(newItems) { if (state.saving) throw new Error('A save is already in progress'); state.saving = true; try { - const full = await ag('/api/config'); + const full = await apiGet('/api/config'); const current = Array.isArray(full.items) ? full.items : []; /* Ids were allocated against the list the preview was built from. */ const taken = new Set(current.map(i => i && i.id)); const clash = newItems.find(i => taken.has(i.id)); if (clash) throw new Error(`${clash.label}: this id already exists. Reload and import again.`); full.items = [...current, ...newItems]; - await ap('/api/config', full); + await apiPost('/api/config', full); state.items = full.items; _savedItems = JSON.stringify(state.items); syncDashSave(); @@ -409,7 +389,7 @@ function openFolderPicker(appId, targetFolderId = null) { }); const ri = document.createElement('span'); ri.className = 'fp-ic'; - ri.style.background = rc(app.color); + ri.style.background = clr(app.color); paintIcon(ri, app.iconUrl, (app.label || '?')[0]); const nm = document.createElement('span'); nm.className = 'fp-nm'; @@ -598,8 +578,8 @@ async function doSave(orig) { orig, state.items.map(i => i.id), ); - if (res.error) { - toast(res.error, 'err'); + if (res.errorKey) { + toast(t(res.errorKey), 'err'); return; } item = res.item; @@ -689,26 +669,11 @@ function initAllInlineEdits() { fetchWallpaperLink(v.trim()); }, }); - - const colorInp = document.createElement('input'); - colorInp.id = 'bg-color-inp'; - document.body.appendChild(colorInp); - initInlineEdit('ie-bgcolor', 'bg-color-inp', { - placeholder: '#0d1117', - onCommit(val) { - if (!val) return; - const { value, ok } = normalizeColorInput(val); - if (!ok) return toast(t('toast.colorInvalid'), 'err'); - colorInp.value = value; - const rv = q('#ie-bgcolor .rv'); - if (rv) rv.textContent = value; - }, - }); } async function initVersion() { try { - const d = await ag('/api/version'); + const d = await apiGet('/api/version'); const v = (d.current || d.version || '').replace(/^v/i, ''); if (v) { const vEl = el('sidebar-version'); @@ -766,10 +731,6 @@ function initBgType() { const apply = val => { hidden.value = val; showBgFields(val); - const hint = el('bgcol-hint'); - if (hint) hint.style.display = val === 'unsplash' ? '' : 'none'; - const imgHint = el('bg-url-hint'); - if (imgHint) imgHint.style.display = val === 'url' ? '' : 'none'; }; const box = createListbox({ id: 'bg-type', @@ -1027,7 +988,7 @@ addEventListener('beforeunload', e => { el('btn-exp').onclick = async () => { let url; try { - const config = await ag('/api/config/export'); + const config = await apiGet('/api/config/export'); const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -1084,8 +1045,8 @@ el('imp').onchange = async e => { const before = snapshotItems(state.items); state.items = d.items; if (await saveOrRevert(before)) toast(t('toast.imported')); - } catch (e) { - toast(t('toast.importFailed', { err: e.message }), 'err'); + } catch (err) { + toast(t('toast.importFailed', { err: err.message }), 'err'); } tgt(e).value = ''; }; diff --git a/public/js/background.js b/public/js/background.js new file mode 100644 index 0000000..b76503d --- /dev/null +++ b/public/js/background.js @@ -0,0 +1,75 @@ +// @ts-check +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=eadafbcd'; +import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; + +export const BACKDROP = '#0d1117'; + +const DEFAULT_BRIGHTNESS = 0.62; + +/** @typedef {{ image: string, color: string, brightness: string, size: string, + url: string|null, fit: 'fit'|'fill' }} Background */ + +/** @param {any} bg @param {string|null} [wallpaperUrl] + @returns {Background|null} */ +export function backgroundFor(bg, wallpaperUrl = null) { + const s = bg || {}; + if (s.type === 'color' && s.color) { + return { + image: 'none', + color: cssColor(s.color, BACKDROP), + brightness: '1', + size: 'cover', + url: null, + fit: 'fill', + }; + } + const url = s.type === 'url' ? s.url : s.type === 'unsplash' ? wallpaperUrl : null; + if (!url) return null; + const fit = s.type === 'url' && s.fit === 'fit' ? 'fit' : 'fill'; + const brightness = Number(s.brightness ?? DEFAULT_BRIGHTNESS); + return { + image: `url('${sanitizeCssUrl(url)}')`, + color: BACKDROP, + brightness: String(Number.isFinite(brightness) ? brightness : DEFAULT_BRIGHTNESS), + size: fit === 'fit' ? 'contain' : 'cover', + url: sanitizeCssUrl(url), + fit, + }; +} + +/** @param {HTMLElement} root @param {Background} b */ +export function applyBackground(root, b) { + root.style.setProperty('--bg-image', b.image); + root.style.setProperty('--bg-color', b.color); + root.style.setProperty('--bg-brightness', b.brightness); + root.style.setProperty('--bg-size', b.size); +} + +async function unsplashUrl(bg) { + const cached = loadWallpaper(bg); + if (cached) return cached; + const r = await fetch('/api/wallpaper', { cache: 'no-store' }); + const d = await r.json(); + const url = d.url || null; + if (url) saveWallpaper(url, bg); + return url; +} + +const decoded = url => + new Promise(resolve => { + const img = new Image(); + img.onload = () => resolve(true); + img.onerror = () => resolve(false); + img.src = url; + }); + +/** @param {any} bg @returns {Promise} */ +export async function resolveBackground(bg) { + const s = bg || {}; + if (s.type === 'unsplash') { + const url = await unsplashUrl(s); + if (!url) return null; + return (await decoded(url)) ? backgroundFor(s, url) : null; + } + return backgroundFor(s); +} diff --git a/public/js/badge-logic.js b/public/js/badge-logic.js index bc61304..e02a1d5 100644 --- a/public/js/badge-logic.js +++ b/public/js/badge-logic.js @@ -64,7 +64,7 @@ export function resolveColor(c, palette) { /** A colour safe to hand to CSS, or ''. The fill lands in a custom property, which stores any string: `url(...)` there becomes a real request. @param {unknown} c @returns {string} */ -export function safeColor(c, palette) { +export function cssUsableColor(c, palette) { const v = resolveColor(c, palette); if (!v) return ''; const supports = globalThis.CSS?.supports; @@ -147,7 +147,7 @@ export function firingLabels(labels, values, palette) { name: String(l.name || l.unit || l.path), value: v, unit: String(l.unit || ''), - color: safeColor(l.color, palette) || safeColor('info', palette) || LABEL_DEFAULT_COLOR, + color: cssUsableColor(l.color, palette) || cssUsableColor('info', palette) || LABEL_DEFAULT_COLOR, }); } return out; @@ -211,11 +211,11 @@ export function computeBadgeVisual({ cls = 'badge on blue'; num = activity > 99 ? `${fmt(99)}+` : fmt(activity); unit = custom.unit ? custom.unit.slice(0, 8) : ''; - bg = safeColor(custom.color, palette); + bg = cssUsableColor(custom.color, palette); } else if (fixed) { cls = 'badge on blue'; num = staticBdg.label.slice(0, 10); - bg = safeColor(staticBdg.color, palette); + bg = cssUsableColor(staticBdg.color, palette); } else if (!hideHealthy && hasHC) { cls = 'badge on green'; } else { @@ -257,7 +257,7 @@ export function computeBadgeVisual({ the reason beside it. */ if (health) rows.push({ name: reason || tr('status.needsAttention'), value: '', unit: '', color: NAMED.red }); for (const f of fired) rows.push(f); - if (fixed) rows.push({ name: staticBdg.label, value: '', unit: '', color: safeColor(staticBdg.color, palette) }); + if (fixed) rows.push({ name: staticBdg.label, value: '', unit: '', color: cssUsableColor(staticBdg.color, palette) }); const more = Math.max(0, rows.length - 1); if (more) { diff --git a/public/js/brand-mark.js b/public/js/brand-mark.js index 1e4c4ff..a82b167 100644 --- a/public/js/brand-mark.js +++ b/public/js/brand-mark.js @@ -3,4 +3,4 @@ name left there shadows the bundled artwork. */ export const BRAND_MARK = - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPgoKPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQuNiw3My40KSBzY2FsZSgwLjg0NikiPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsMTAyNCkgc2NhbGUoMC4xLC0wLjEpIj48cGF0aCBmaWxsPSIjMDBEMkUwIiBkPSJNNDk3OCA5MTgwIGMtNzUgLTEzIC0xNDYgLTM5IC0yMzMgLTgzIC0xMTIgLTU3IC0yNDQgLTE5MCAtMzAyIC0zMDIgLTcwIC0xMzUgLTg4IC0yMTAgLTg4IC0zNjAgMCAtMTUwIDE4IC0yMjUgODggLTM2MCA1OCAtMTEyIDE5MCAtMjQ0IDMwMiAtMzAyIDE0MSAtNzIgMTkzIC04NCA0MzAgLTkzIDM1NCAtMTUgNjAyIC02NCA4ODUgLTE3NyA4MDMgLTMxOSAxNDAwIC0xMDI4IDE1NjkgLTE4NjMgMzkgLTE5NSA0NiAtMjcyIDQ2IC01MjAgMCAtMTU1IC01IC0yNzggLTE0IC0zMzUgLTUwIC0zMzIgLTEyOSAtNTc4IC0yODEgLTg3MCAtMTA4IC0yMDcgLTEyNSAtMjY2IC0xMjUgLTQzMCAwIC0xNTAgMTggLTIyNSA4OCAtMzYwIDU4IC0xMTIgMTkwIC0yNDQgMzAyIC0zMDIgMTM1IC03MCAyMTAgLTg4IDM2MCAtODggMTA4IDAgMTQzIDQgMjA1IDI0IDE3NCA1NCAzMzAgMTY3IDQyNCAzMDkgNjUgMTAwIDE5NSAzNjYgMjY5IDU1MiAxMzUgMzQzIDIxOSA2NzcgMjYzIDEwNTUgMTMgMTExIDE4IDIyNiAxOCA0NDUgMCAzMTggLTEzIDQ3MyAtNjAgNzMwIC0xMDIgNTUwIC0zMDkgMTA2NCAtNjE3IDE1MjUgLTY2OCAxMDAyIC0xNzQyIDE2NTYgLTI5MzcgMTc5MCAtMTY5IDE5IC01MjQgMjggLTU5MiAxNXoiLz48cGF0aCBmaWxsPSIjRjJGMkY3IiBkPSJNMjEzMSA3NTQ5IGMtMjg3IC01MSAtNTI5IC0yNjQgLTYyMyAtNTQ5IC0yOSAtODcgLTMyIC0xMDYgLTMyIC0yMzAgMCAtMTExIDMgLTE0OSAyMiAtMjEzIDU1IC0xOTEgMTczIC0zNTQgMzMwIC00NTQgMzIgLTIxIDQyNiAtMjQ5IDg3NSAtNTA4IDcwNyAtNDA3IDgxNCAtNDcyIDc5OSAtNDgzIC05IC03IC0zOTYgLTIzMiAtODYxIC01MDAgLTc0NyAtNDMxIC04NTIgLTQ5NSAtOTE3IC01NTUgLTMxNCAtMjkzIC0zMzggLTc2MyAtNTYgLTExMDQgODUgLTEwMyAyNTcgLTIxMCA0MDIgLTI0OSA0NyAtMTMgMTAyIC0xOCAxOTUgLTE4IDE0NiAwIDIxNCAxNCAzMzAgNjkgMzkgMTggNDQxIDI0NyA4OTQgNTA5IDQ1MyAyNjIgODI2IDQ3NiA4MjggNDc2IDEgMCAzIC00NDUgMyAtOTg5IDAgLTEwNzkgLTEgLTEwNjMgNTggLTEyMTMgNzcgLTE5OSAyNDkgLTM2OSA0NDggLTQ0NCA0NzAgLTE3NyA5NzYgMTA0IDEwNzAgNTkzIDEyIDYzIDE0IDMzOSAxNCAxNzIzIDAgMTA2NCAtNCAxNjg1IC0xMCAxNzUxIC0yNSAyNDcgLTExOSA0MzUgLTI4NCA1NzEgLTYxIDUxIC0yODQ0IDE2NjYgLTMwMTQgMTc0OSAtNDAgMjEgLTEwNCA0NSAtMTQwIDU0IC03OSAyMCAtMjU1IDI4IC0zMzEgMTR6Ii8+PC9nPjwvZz4KPC9zdmc+Cg=='; + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPgoKPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQuNiw3My40KSBzY2FsZSgwLjg0NikiPjxwYXRoIGZpbGw9IiMwMEQyRTAiIGQ9Ik01MTIgMTA1LjVBNDA2LjUgNDA2LjUgMCAwIDEgODY0LjA0IDcxNS4yNUE3NS41IDc1LjUgMCAwIDEgNzMzLjI3IDYzOS43NUEyNTUuNSAyNTUuNSAwIDAgMCA1MTIgMjU2LjVBNzUuNSA3NS41IDAgMCAxIDUxMiAxMDUuNVoiLz48cGF0aCBmaWxsPSIjRjJGMkY3IiBkPSJNNTUxLjUgNDQzLjU4TDI2NC44NSAyNzguMDhBNzkgNzkgMCAwIDAgMTg1Ljg1IDQxNC45Mkw0NzIuNSA1ODAuNDJBNzkgNzkgMCAwIDAgNTUxLjUgNDQzLjU4Wk00NzIuNSA0NDMuNThMMTg1Ljg1IDYwOS4wOEE3OSA3OSAwIDAgMCAyNjQuODUgNzQ1LjkyTDU1MS41IDU4MC40MkE3OSA3OSAwIDAgMCA0NzIuNSA0NDMuNThaTTQzMyA1MTJMNDMzIDg0M0E3OSA3OSAwIDAgMCA1OTEgODQzTDU5MSA1MTJBNzkgNzkgMCAwIDAgNDMzIDUxMloiLz48L2c+Cjwvc3ZnPgo='; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 4d8ed12..aeb9726 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -11,6 +11,7 @@ import { } from '/js/widget-types.js?v=9264dee5'; import { el, + ICON_R, isDashboardEmpty, mk, mkWrap as _mkWrap, @@ -20,13 +21,12 @@ import { q, qa, qi, - sanitizeCssUrl, setUserText, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=55685187'; +} from '/js/utils.js?v=eadafbcd'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=f050b4e5'; +import { initSpotlight } from '/js/spotlight.js?v=9056fab5'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=1f1ea9c1'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7'; @@ -40,8 +40,8 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=d8363297'; -import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=ad283693'; +} from '/js/ui.js?v=cdfe7685'; +import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=9e6d9d4b'; import { formatNumber } from '/js/format-number.js?v=4a5ccef4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3'; import { observeGlass } from '/js/glass-rim.js?v=3faec233'; @@ -52,14 +52,14 @@ import { landingAfterSetup, restorePage, } from '/js/dashboard-logic.js?v=0d519f8b'; -import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=175d9a7f'; import { jitter } from '/js/jitter.js?v=4eeef4c9'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=08b74a28'; import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43'; -import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=787bfdff'; +import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5'; ensureSprite(); @@ -69,10 +69,6 @@ let MOB = isMobileLayout(); const wCols = { d: WIDGET_COLS.desktop, m: WIDGET_COLS.mobile }; const wRows = { d: WIDGET_ROWS.desktop, m: WIDGET_ROWS.mobile }; const WH = { d: WIDGET_HEIGHTS }; -/* An app icon's corner is 22.37% of its width. A fixed radius only lands on that - at one icon size, and the grid draws two: 72 with a label under it, 78 - without. */ -const ICON_R = 0.2237; /* A widget tile's corner, at design size. WIDGET_DESIGN's small is 170 square against the reference's 165, so this is its 28 unchanged. */ const WIDGET_R = 28; @@ -166,16 +162,16 @@ const BEL = new Map(); /* Last painted appearance per badge element. Without it the same values are rewritten thousands of times a session. */ const BSIG = new WeakMap(); -function breg(id, el) { +function breg(id, badge) { if (!BEL.has(id)) BEL.set(id, new Set()); - BEL.get(id).add(el); + BEL.get(id).add(badge); /* Paint on registration. A poll only reaches ids it returns, so a fixed label alone stays invisible, and a rebuild blanks every badge until the next poll. */ bupd(id); } -function bunreg(id, el) { - if (BEL.has(id)) BEL.get(id).delete(el); +function bunreg(id, badge) { + if (BEL.has(id)) BEL.get(id).delete(badge); } function bupd(id) { const els = BEL.get(id); @@ -209,18 +205,18 @@ function bupd(id) { const sig = badgeSignature({ cls, txt, unit, bg, aria, color, nextColor, rows }); - els.forEach(el => { - if (BSIG.get(el) === sig) return; - BSIG.set(el, sig); - el.className = cls; - let txtEl = el.firstElementChild; + els.forEach(badge => { + if (BSIG.get(badge) === sig) return; + BSIG.set(badge, sig); + badge.className = cls; + let txtEl = badge.firstElementChild; let unitEl = txtEl?.nextElementSibling; if (!txtEl) { txtEl = document.createElement('span'); txtEl.className = 'badge-txt'; unitEl = document.createElement('span'); unitEl.className = 'badge-unit'; - el.append(txtEl, unitEl); + badge.append(txtEl, unitEl); } txtEl.textContent = num; unitEl.textContent = unit ? ' ' + unit : ''; @@ -229,24 +225,24 @@ function bupd(id) { badge left to speak for itself is never reached by a reader moving from tile to tile. A name change is silent, which is what a figure on a timer needs. */ - el.setAttribute('aria-hidden', 'true'); - el.removeAttribute('role'); - el.removeAttribute('aria-label'); - const tile = /** @type {HTMLElement|null} */ (el.closest('a, button, [role="button"]')); + badge.setAttribute('aria-hidden', 'true'); + badge.removeAttribute('role'); + badge.removeAttribute('aria-label'); + const tile = /** @type {HTMLElement|null} */ (badge.closest('a, button, [role="button"]')); const tileName = tile?.dataset.tileName; if (tile && tileName) tile.setAttribute('aria-label', aria ? `${tileName}, ${aria}` : tileName); /* Never the `background` shorthand. It resets background-clip, and the pill behind is painted from this same value. */ - if (bg) el.style.setProperty('--badge-bg', bg); - else el.style.removeProperty('--badge-bg'); - el.style.color = color; - if (nextColor) el.style.setProperty('--badge-next', nextColor); - else el.style.removeProperty('--badge-next'); + if (bg) badge.style.setProperty('--badge-bg', bg); + else badge.style.removeProperty('--badge-bg'); + badge.style.color = color; + if (nextColor) badge.style.setProperty('--badge-next', nextColor); + else badge.style.removeProperty('--badge-next'); /* No title attribute. The popover carries the reason, and a badge that can be hovered would otherwise draw the browser's own tooltip beside it. Removed rather than skipped: one set by an earlier paint would persist. */ - el.removeAttribute('title'); - wireBadgePopover(el, popover ? rows : null); + badge.removeAttribute('title'); + wireBadgePopover(badge, popover ? rows : null); }); } @@ -428,7 +424,7 @@ function buildDesktop() { dk.replaceChildren(); dock.forEach(item => dk.appendChild(mkDock(item))); dk.hidden = !dock.length; - observeGlass(dk, 45, 0.2); + observeGlass(dk, 38, 0.6); } /* Every page is mounted at once, so widgets the user has swiped away from keep @@ -489,8 +485,8 @@ function goTo(n, dotEls, announce = true) { storeSet(PAGE_STORE, String(pg)); const strip = el('pages'); syncPageInert(pg); - const t = `translateX(${-pageDir() * pg * 100}vw)`; - strip.style.transform = strip.style.webkitTransform = t; + const shift = `translateX(${-pageDir() * pg * 100}vw)`; + strip.style.transform = strip.style.webkitTransform = shift; strip.style.willChange = 'transform'; strip.addEventListener( 'transitionend', @@ -537,7 +533,6 @@ function syncMobPages() { /* Matches --bg-base in tokens.css. It shows wherever a fitted wallpaper does not reach. */ -const WALLPAPER_BACKDROP = '#0d1117'; /* A sampled wallpaper grid, or one tone for a solid colour. Null on both leaves the labels as they are. */ @@ -574,7 +569,7 @@ function resampleBg() { window.innerHeight, _bgSample.brightness, _bgSample.fit, - WALLPAPER_BACKDROP, + BACKDROP, ), tone: themeTone(), }; @@ -585,48 +580,15 @@ async function applyBg() { const root = document.documentElement; bgTone = { grid: null, tone: themeTone() }; retone(); - try { - const bg = S.background || {}; - if (bg.type === 'color' && bg.color) { - const safeColor = String(bg.color).replace(/[^a-zA-Z0-9#(),.\s%]/g, ''); - root.style.setProperty('--bg-image', 'none'); - root.style.setProperty('--bg-color', safeColor); - root.style.setProperty('--bg-brightness', '1'); - root.style.setProperty('--bg-size', 'cover'); - bgTone = { grid: null, tone: toneForColor(safeColor) ?? themeTone() }; - retone(); - } else if (bg.type === 'url' && bg.url) { - const url = sanitizeCssUrl(bg.url); - const brightness = Number(bg.brightness ?? 0.62); - const fit = bg.fit === 'fit' ? 'fit' : 'fill'; - root.style.setProperty('--bg-image', `url('${url}')`); - root.style.setProperty('--bg-color', WALLPAPER_BACKDROP); - root.style.setProperty('--bg-brightness', String(brightness)); - root.style.setProperty('--bg-size', fit === 'fit' ? 'contain' : 'cover'); - sampleWallpaper(url, brightness, fit); - } else if (bg.type === 'unsplash') { - let url = loadWallpaper(bg); - if (!url) { - const r = await fetch('/api/wallpaper', { cache: 'no-store' }); - const d = await r.json(); - url = d.url || null; - if (url) saveWallpaper(url, bg); - } - if (url) { - const shown = url; - const brightness = Number(bg.brightness ?? 0.62); - const img = new Image(); - img.onload = () => { - root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(shown)}')`); - root.style.setProperty('--bg-color', WALLPAPER_BACKDROP); - root.style.setProperty('--bg-brightness', String(brightness)); - root.style.setProperty('--bg-size', 'cover'); - sampleWallpaper(sanitizeCssUrl(shown), brightness, 'fill'); - }; - img.src = shown; - } - } - } catch {} + const bg = await resolveBackground(S.background || {}); + if (!bg) return; + applyBackground(root, bg); + if (bg.url) { + sampleWallpaper(bg.url, Number(bg.brightness), bg.fit); + return; + } + bgTone = { grid: null, tone: toneForColor(bg.color) ?? themeTone() }; + retone(); } function refreshBadges() { diff --git a/public/js/icon-set.js b/public/js/icon-set.js index 172afc7..c1c6d8a 100644 --- a/public/js/icon-set.js +++ b/public/js/icon-set.js @@ -112,6 +112,34 @@ const TOGGLES = { }; const CLOUD = 'M6 20h10a4 4 0 0 0 0-8 5 5 0 0 0-9.6 1.2A3.5 3.5 0 0 0 6 20z'; +/* Keep the fractions. Whole units drift from the logo files. */ +function logoIcon() { + const f = n => +n.toFixed(3); + const k = 24 / 1024; + const R = 331 * k; + const wA = 151 * k; + const wB = 158 * k; + const at = (deg, r) => [f(12 + r * Math.cos((deg * Math.PI) / 180)), f(12 + r * Math.sin((deg * Math.PI) / 180))]; + const capsule = end => { + const [dx, dy] = [end[0] - 12, end[1] - 12]; + const n = [f((-dy / R) * (wB / 2)), f((dx / R) * (wB / 2))]; + const r = f(wB / 2); + const d = `M${12 + n[0]} ${12 + n[1]}L${f(end[0] + n[0])} ${f(end[1] + n[1])}A${r} ${r} 0 0 0 ${f(end[0] - n[0])} ${f(end[1] - n[1])}L${12 - n[0]} ${12 - n[1]}A${r} ${r} 0 0 0 ${12 + n[0]} ${12 + n[1]}Z`; + return shape(d, 'ink', { lineAlt: { k: 'line', d: `M12 12L${end}` } }); + }; + const [ro, ri, h] = [f(R + wA / 2), f(R - wA / 2), f(wA / 2)]; + const arc = `M${at(-90, ro)}A${ro} ${ro} 0 0 1 ${at(30, ro)}A${h} ${h} 0 0 1 ${at(30, ri)}A${ri} ${ri} 0 0 0 ${at(-90, ri)}A${h} ${h} 0 0 1 ${at(-90, ro)}Z`; + return { + inkMask: false, + shapes: [ + capsule(at(210, R)), + capsule(at(150, R)), + capsule(at(90, R)), + shape(arc, 'accent', { lineAlt: { k: 'line', d: `M${at(-90, R)}A${f(R)} ${f(R)} 0 0 1 ${at(30, R)}` } }), + ], + }; +} + function sizeIcon(k) { const [x, y, w, h] = { small: [5, 5, 14, 14], medium: [2, 7, 20, 10], large: [3, 3, 18, 18], xlarge: [5, 2, 14, 20] }[ k @@ -220,7 +248,14 @@ export const ICONS = { 'bright-hi': { shapes: [dot(12, 12, 4), line('M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1 1M18 18l1 1M5 19l1-1M18 6l1-1')] }, external: { shapes: [line('M14 4h6v6M20 4l-9 9'), line('M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4')] }, docs: { shapes: [rect(4, 2, 16, 20, T.R_MD), bar(7, 7, 6), bar(7, 11, 10), bar(7, 15, 8)] }, - issue: { shapes: [circle(12, 12, 10), line('M12 7v6', 'cut'), detail(11, 16, 2, 2, 1, 'cut')] }, + bug: { + shapes: [ + shape('M9 6a3 3 0 0 1 6 0z', 'accent'), + shape('M6 14a6 6 0 0 1 12 0v2a6 6 0 0 1-12 0z'), + line('M12 11v8', 'cut'), + line('M2 9l2 2M2 15h2M2 21l2-2M22 9l-2 2M22 15h-2M22 21l-2-2'), + ], + }, info: { shapes: [circle(12, 12, 10), line('M12 11v6', 'cut'), detail(11, 6, 2, 2, 1, 'cut')] }, heart: { shapes: [shape('M12 21l-8-8a5 5 0 0 1 8-6 5 5 0 0 1 8 6z')] }, grip: { @@ -237,6 +272,11 @@ export const ICONS = { search: { shapes: [ring(10, 10, 7), line('M15 15l6 6')] }, 'arrow-down': { shapes: [line('M12 4v15M6 13l6 6 6-6')] }, 'arrow-up': { shapes: [line('M12 20V5M6 11l6-6 6 6')] }, + + logo: logoIcon(), + start: { inkMask: false, shapes: [line('M12 2v9M8 7l4 4 4-4', 'accent'), rect(2, 14, 20, 8, 4), bar(5, 17, 6)] }, + run: { gap: 0, shapes: [rect(2, 4, 20, 16, 4), line('M6 9l3 3-3 3', 'accent'), bar(12, 14, 6)] }, + create: { shapes: [rect(3, 5, 16, 16, 4), line('M19 3v4M17 5h4', 'accent'), bar(6, 10, 6), bar(6, 14, 10)] }, }; export function iconSvg(id, px = 24, variant = 'solid', cls = '') { diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js index fb87f57..6097b16 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=858f3f84'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=60a82419'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ @@ -188,8 +188,8 @@ function addApp(col, group, { label, href, iconUrl, container, pingUrl, skipTlsV null, col.taken, ); - if (built.error) { - col.skip(SKIP.NO_HREF, label, group, built.error); + if (built.errorKey) { + col.skip(SKIP.NO_HREF, label, group, built.errorKey); return null; } col.taken.add(built.item.id); diff --git a/public/js/listbox.js b/public/js/listbox.js index 91cf92f..4f4f102 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=e3673bd7'; +import { nextActiveIndex } from '/js/admin-logic.js?v=cbb7417d'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=55685187'; -import { iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { qa } from '/js/utils.js?v=eadafbcd'; +import { iconSvg } from '/js/icon-set.js?v=08b74a28'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/public/js/mobile-metrics.js b/public/js/mobile-metrics.js index 43eb283..a4966be 100644 --- a/public/js/mobile-metrics.js +++ b/public/js/mobile-metrics.js @@ -41,7 +41,7 @@ export function mobileMetrics(vw, docked = true) { const sc = Math.min(vw / BASE_VW, MAX_SC); const sm = Math.round(18 * sc); const dh = docked ? Math.round(108 * sc) : 0; - const pillH = Math.round(34 * sc); + const pillH = Math.round(30 * sc); const pillGap = Math.round(10 * sc); /* Reserving less lets the pill sit on top of the last row. */ const dz = pillGap + pillH + Math.round(8 * sc); diff --git a/public/js/spotlight.js b/public/js/spotlight.js index 99ae680..edd3ac5 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, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=55685187'; +import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=eadafbcd'; import { t } from '/js/i18n.js?v=1f1ea9c1'; /* Attached to the window so a re-open can undo the previous one. */ @@ -28,10 +28,10 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto res.setAttribute('role', 'listbox'); res.setAttribute('aria-label', t('home.searchResults')); - const render = q => { + const render = query => { res.replaceChildren(); const apps = getItems().filter(i => i.type === 'app' || i.type === 'folder'); - const qq = q.toLowerCase().trim(); + const qq = query.toLowerCase().trim(); cur = qq ? apps.filter(a => (a.label || a.id).toLowerCase().includes(qq)) : apps; si = 0; if (!cur.length) { @@ -65,7 +65,8 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto a.setAttribute('aria-selected', i === 0 ? 'true' : 'false'); a.style.cssText = 'display:flex;align-items:center;gap:18px;padding:14px 20px;text-decoration:none;cursor:pointer;-webkit-tap-highlight-color:transparent;'; - const doOpen = () => { + /* A click also follows the link. Opening here as well opens two tabs. */ + const doOpen = viaTouch => { close(); if (isFolder) { if (MOB()) { @@ -74,15 +75,15 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto } else openFolderDesktop(app); } else if (app.system === 'settings' && app.href) { window.location.href = app.href; - } else if (app.href) { + } else if (viaTouch && app.href) { window.open(app.href, '_blank', 'noreferrer,noopener'); } }; a.addEventListener('touchend', e => { e.preventDefault(); - doOpen(); + doOpen(true); }); - a.onclick = doOpen; + a.onclick = () => doOpen(false); const ic = mk('div'); ic.className = 'sri'; diff --git a/public/js/theme.js b/public/js/theme.js index 3a8dec9..7394727 100644 --- a/public/js/theme.js +++ b/public/js/theme.js @@ -12,12 +12,10 @@ export function normaliseMode(value) { return THEME_MODES.includes(/** @type {any} */ (value)) ? String(value) : 'system'; } -/** The theme a mode resolves to. - @param {string} mode - @param {boolean} prefersDark whether the device asks for a dark appearance */ -export function resolveTheme(mode, prefersDark) { +/** @param {string} mode @param {boolean} systemDark */ +export function resolveTheme(mode, systemDark) { const m = normaliseMode(mode); - if (m === 'system') return prefersDark ? 'dark' : 'light'; + if (m === 'system') return systemDark ? 'dark' : 'light'; return m; } diff --git a/public/js/ui.js b/public/js/ui.js index 4f897d2..810ff72 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -6,6 +6,7 @@ import { isDashboardEmpty, renderEmptyState, mkWrap as _mkWrap, + ICON_R, mountScaledWidget, pageDir, teardownWidgets, @@ -13,10 +14,10 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=55685187'; +} from '/js/utils.js?v=eadafbcd'; import { t, currentLang } from '/js/i18n.js?v=1f1ea9c1'; import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; -import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=ab5fe77e'; +import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; import { mkGlassRim, observeGlass } from '/js/glass-rim.js?v=3faec233'; @@ -62,9 +63,9 @@ if (!_win.__wActiveMsgBound) { }); } -function css(el, props) { - for (const [k, v] of Object.entries(props)) el.style.setProperty(k, v); - return el; +function css(node, props) { + for (const [k, v] of Object.entries(props)) node.style.setProperty(k, v); + return node; } function mkMiniIcon(child, pointerEvents) { @@ -110,7 +111,7 @@ export function mkFolderGlyph(folder, size) { const wrap = mk('div'); wrap.className = 'dyn-fold-wrap glass-surface'; css(wrap, { - '--br': Math.round(size * 0.24) + 'px', + '--br': Math.round(size * ICON_R) + 'px', '--gap': Math.round(size * 0.04) + 'px', '--pad': Math.round(size * 0.1) + 'px', }); @@ -220,7 +221,7 @@ export function openFolderDesktop(folder) { box.appendChild(grid); const registeredBadges = []; children.forEach(c => bupd(c.id)); - qa('.badge', grid).forEach(el => registeredBadges.push(el)); + qa('.badge', grid).forEach(badge => registeredBadges.push(badge)); function closeDesk() { ov.close(); } @@ -229,7 +230,7 @@ export function openFolderDesktop(folder) { let stopGlass = () => {}; ov.addEventListener('close', () => { stopGlass(); - registeredBadges.forEach(el => BEL().forEach((_, id) => bunreg(id, el))); + registeredBadges.forEach(badge => BEL().forEach((_, id) => bunreg(id, badge))); ov.remove(); folderOverlay = null; }); @@ -311,7 +312,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { css(box, { '--sz': eff + 'px' }); box.style.pointerEvents = 'none'; const wrap = mkFolderGlyph(item, eff); - wrap.appendChild(mkGlassRim(eff, eff, smoothRectPath(eff, eff, Math.round(eff * 0.24), 0))); + wrap.appendChild(mkGlassRim(eff, eff, smoothRectPath(eff, eff, Math.round(eff * ICON_R), 0))); box.appendChild(wrap); const fb_ = mk('div'); fb_.className = 'badge'; @@ -322,7 +323,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { const l = mk('div'); l.className = 'dyn-fold-label'; l.dir = 'auto'; - css(l, { '--lfs': Math.max(9, Math.round(9 * sc)) + 'px', '--lw': cw - 4 + 'px' }); + css(l, { '--lfs': Math.max(11, Math.round(12 * sc)) + 'px', '--lw': cw - 4 + 'px' }); setUserText(l, item.label || t('type.folder')); a.appendChild(l); } @@ -355,7 +356,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { /* A badge registered to an element that has gone keeps the dashboard repainting it. */ ov.addEventListener('close', () => { - qa('.badge', ov).forEach(el => BEL().forEach((_, id) => bunreg(id, el))); + qa('.badge', ov).forEach(badge => BEL().forEach((_, id) => bunreg(id, badge))); ov.remove(); folderOverlayMob = null; }); @@ -372,13 +373,13 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { const innerW = boxW - padH * 2, gap = Math.round(14 * ptScale); const folderIconW = Math.min(Math.floor((innerW - gap * 2) / 3), isz); - const folderIr = Math.round(folderIconW * 0.22), + const folderIr = Math.round(folderIconW * ICON_R), folderIm = Math.round(folderIconW * 0.64); const gridInnerW = folderIconW * 3 + gap * 2, gridH = folderIconW * 3 + gap * 2; - /* A badge sits 7px outside its icon corner. Without this inset the outer + /* A badge sits 11px outside its icon corner. Without this inset the outer icons' badges cross into the next page and the viewport shows them. */ - const badgeOvh = Math.min(Math.ceil(7 * (sc || 1)), padH, padVT, padVB); + const badgeOvh = Math.min(Math.ceil(11 * (sc || 1)), padH, padVT, padVB); const pageW = gridInnerW + badgeOvh * 2, pageH = gridH + badgeOvh * 2; const dotSz = Math.round(7 * ptScale); @@ -545,9 +546,9 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { ov.addEventListener( 'touchend', e => { - const t = e.changedTouches[0], + const touch = e.changedTouches[0], rb = box.getBoundingClientRect(); - if (t.clientX < rb.left || t.clientX > rb.right || t.clientY < rb.top || t.clientY > rb.bottom) { + if (touch.clientX < rb.left || touch.clientX > rb.right || touch.clientY < rb.top || touch.clientY > rb.bottom) { e.preventDefault(); e.stopPropagation(); closeMob(); @@ -641,7 +642,7 @@ export function buildMobile() { css(document.body, { '--mgw': Math.round(gridW) + 'px' }); const maxIsz = Math.round(74 * sc); const isz = Math.round(Math.min(cw * 0.9, rh * 0.8, maxIsz)); - const ir = Math.round(isz * 0.225), + const ir = Math.round(isz * ICON_R), im = Math.round(isz * 0.64); const showLabel = S().showLabels?.ios === true; /* The reference draws a 66 icon and a 28 widget corner, so a widget's corner @@ -705,7 +706,7 @@ export function buildMobile() { function mIcon(item) { const eff = showLabel ? Math.round(isz * 0.82) : isz; - const er = Math.round(eff * 0.225), + const er = Math.round(eff * ICON_R), em = Math.round(eff * 0.64); const a = item.system === 'settings' @@ -720,7 +721,7 @@ export function buildMobile() { const l = mk('div'); l.className = 'dyn-mob-label'; l.dir = 'auto'; - css(l, { '--lfs': Math.max(9, Math.round(9 * sc)) + 'px', '--lw': '100%' }); + css(l, { '--lfs': Math.max(11, Math.round(12 * sc)) + 'px', '--lw': '100%' }); setUserText(l, item.label || item.id); a.appendChild(l); } @@ -786,7 +787,7 @@ export function buildMobile() { const maxDockW = vw - Math.round(18 * sc); const dockPad = Math.round(14 * sc); const dockIconSz = Math.round(Math.min(isz, ((maxDockW - Math.round(28 * sc)) / 4) * 0.85)); - const dockIr = Math.round(dockIconSz * 0.225), + const dockIr = Math.round(dockIconSz * ICON_R), dockIm = Math.round(dockIconSz * 0.64); const dockGap = Math.round(9 * sc); const dockW = Math.min(maxDockW, Math.round((PHONE_W - 18) * sc)); @@ -796,7 +797,7 @@ export function buildMobile() { dk.hidden = !dock.length; dk.style.cssText = `position:fixed;left:50%;bottom:${dockGap}px;transform:translateX(-50%);width:${dockW}px;height:${dh}px;padding:0 ${dockPad}px;gap:${dockIconGap}px;z-index:400;`; dk.replaceChildren(); - observeGlass(dk, Math.round(40 * sc), 0.2); + observeGlass(dk, Math.round(38 * sc), 0.6); dock.forEach(item => { const a = mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' }); a.className = 'dyn-dock-icon'; diff --git a/public/js/utils.js b/public/js/utils.js index 9efdbc9..7a94913 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -3,12 +3,12 @@ import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; import { SETTINGS_ICON, SETTINGS_ICON_LIGHT } from '/js/settings-icon.js?v=4079b66a'; import { mkGlassRim } from '/js/glass-rim.js?v=3faec233'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; -import { BRAND_MARK } from '/js/brand-mark.js?v=1dcbf1ac'; +import { BRAND_MARK } from '/js/brand-mark.js?v=e363e477'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; -export const mk = (t, a = {}) => { - const e = document.createElement(t); +export const mk = (tag, a = {}) => { + const e = document.createElement(tag); Object.assign(e, a); return e; }; @@ -17,16 +17,24 @@ export const mk = (t, a = {}) => { url() fetches from whatever host it names. */ const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([0-9a-z%.,\s/+-]*\)|[a-z]{3,20})$/i; const DEFAULT_TILE_COLOR = '#1C1C1E'; + +/** Reject an invalid colour. Repairing one by deleting characters paints a + colour nobody chose. + @param {unknown} c @param {string} fallback @returns {string} */ +export const cssColor = (c, fallback) => { + const v = String(c ?? '').trim(); + return v && SAFE_COLOR.test(v) ? v : fallback; +}; + export const clr = c => { if (!c) return DEFAULT_TILE_COLOR; const named = tileColor(c, pageTheme()); if (named) return named; - const v = String(c).trim(); - return SAFE_COLOR.test(v) ? v : DEFAULT_TILE_COLOR; + return cssColor(c, DEFAULT_TILE_COLOR); }; /* The plate is a colour the user chose, so the ink has to be measured from it. White on the palette's own yellow reads at 1.5:1. */ -export const fb = (l, sz, plate) => { +export const letterTile = (l, sz, plate) => { const e = mk('span'); e.className = 'fb'; const tone = toneForColor(plate) ?? (pageTheme() === 'light' ? 'dark' : 'light'); @@ -113,6 +121,8 @@ export const qa = (sel, root = document) => /** @type {HTMLElement[]} */ ([...ro @param {Event} e @returns {HTMLInputElement} */ export const tgt = e => /** @type {HTMLInputElement} */ (e.target); +export const ICON_R = 0.26; + /* breg is passed in to avoid a circular import. */ export function mkWrap(item, sz, r, isz, cls, breg) { const w = mk('div'); @@ -134,7 +144,7 @@ export function mkWrap(item, sz, r, isz, cls, breg) { }); img.setAttribute('aria-hidden', 'true'); img.style.cssText = `width:${si}px;height:${si}px;object-fit:contain;position:relative;z-index:3;`; - img.onerror = () => img.replaceWith(fb(item.label, sz, wrapBg)); + img.onerror = () => img.replaceWith(letterTile(item.label, sz, wrapBg)); w.appendChild(img); } else if (rawIcon) { const chain = iconChain(rawIcon); @@ -146,7 +156,7 @@ export function mkWrap(item, sz, r, isz, cls, breg) { const tryNext = () => { step++; if (step < chain.length) img.src = chain[step]; - else img.replaceWith(fb(item.label, sz, wrapBg)); + else img.replaceWith(letterTile(item.label, sz, wrapBg)); }; img.onerror = tryNext; /* A 403 fires load, not onerror. A blocked image has zero dimensions. */ @@ -154,8 +164,8 @@ export function mkWrap(item, sz, r, isz, cls, breg) { if (img.naturalWidth === 0) tryNext(); }; w.appendChild(img); - } else w.appendChild(fb(item.label, sz, wrapBg)); - } else w.appendChild(fb(item.label, sz, wrapBg)); + } else w.appendChild(letterTile(item.label, sz, wrapBg)); + } else w.appendChild(letterTile(item.label, sz, wrapBg)); if ( breg && (item.monitoring?.healthcheck?.enabled || @@ -320,10 +330,10 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla doc.addEventListener( 'touchstart', e => { - const t = e.touches[0]; - if (!t) return; - sx = t.clientX; - sy = t.clientY; + const touch = e.touches[0]; + if (!touch) return; + sx = touch.clientX; + sy = touch.clientY; moved = false; }, { passive: true }, @@ -331,19 +341,19 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla doc.addEventListener( 'touchmove', e => { - const t = e.touches[0]; - if (!t) return; - if (Math.abs(t.clientX - sx) > 8 || Math.abs(t.clientY - sy) > 8) moved = true; + const touch = e.touches[0]; + if (!touch) return; + if (Math.abs(touch.clientX - sx) > 8 || Math.abs(touch.clientY - sy) > 8) moved = true; }, { passive: true }, ); doc.addEventListener( 'touchend', e => { - const t = e.changedTouches[0]; - if (!t) return; - const dx = t.clientX - sx, - dy = t.clientY - sy; + const touch = e.changedTouches[0]; + if (!touch) return; + const dx = touch.clientX - sx, + dy = touch.clientY - sy; if (Math.abs(dx) > 30 && Math.abs(dx) > Math.abs(dy) * 1.4) { /* horizontal swipe → page */ if (typeof onSwipe === 'function') onSwipe(dx < 0 ? 1 : -1); @@ -351,11 +361,11 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla } if (!moved && overlayHref) { /* tap on non-interactive area → open link */ - const tgt = e.target; + const target = e.target; const interactive = - tgt && - tgt.closest && - tgt.closest( + target && + target.closest && + target.closest( 'a,button,[role="button"],[onclick],.clickable,.bay,.val-row,.chart-wrap,input,select,textarea', ); if (!interactive) window.open(overlayHref, '_blank', 'noopener,noreferrer'); diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index f5f6b5b..e8c36ec 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=1f1ea9c1'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=52e149f5'; -import { createListbox } from '/js/listbox.js?v=32f787c3'; -import { renderColorControl } from '/js/admin-color-control.js?v=42c11a9b'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=d2e8b6dc'; +import { createListbox } from '/js/listbox.js?v=f057351d'; +import { renderColorControl } from '/js/admin-color-control.js?v=e963853a'; import { seedCarried, applyOptionSet, @@ -13,9 +13,9 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=e3673bd7'; -import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; -import { qi } from '/js/utils.js?v=55685187'; +} from '/js/admin-logic.js?v=cbb7417d'; +import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=e4ea7820'; +import { qi } from '/js/utils.js?v=eadafbcd'; const PE = ''; @@ -227,7 +227,7 @@ function _picklist(field, value, ctx, size) { status.className = 'row-status ok'; } catch (e) { const advice = optionsErrorAdvice(e); - status.textContent = advice.message; + status.textContent = t(advice.key, advice.vars); status.className = 'row-status ' + (advice.tone === TONE.WARN ? 'warn' : 'err'); } finally { btn.disabled = false; @@ -318,7 +318,7 @@ function _select(field, value, ctx, config = {}) { status.className = 'row-status ok'; } catch (e) { const advice = optionsErrorAdvice(e); - status.textContent = advice.message; + status.textContent = t(advice.key, advice.vars); status.className = 'row-status ' + (advice.tone === TONE.WARN ? 'warn' : 'err'); } finally { btn.disabled = false; diff --git a/public/js/widget-glyphs.js b/public/js/widget-glyphs.js index 9025b7f..94382e1 100644 --- a/public/js/widget-glyphs.js +++ b/public/js/widget-glyphs.js @@ -1,5 +1,5 @@ // @ts-check -import { iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { iconSvg } from '/js/icon-set.js?v=08b74a28'; /* Keys are the glyph names in widget.json files. Renaming one breaks that widget. */ /** @type {Record} */ diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js index 64ab9d2..85a360b 100644 --- a/public/js/widget-toolbox.js +++ b/public/js/widget-toolbox.js @@ -12,7 +12,7 @@ export { esc, html, setHtml }; survives esc() and still parses as a second declaration. Assign the result through a specific CSSOM property, never a concatenated style string. */ const COLOR_RE = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i; -export function safeColor(value, fallback) { +export function colorOrFallback(value, fallback) { return COLOR_RE.test(String(value ?? '').trim()) ? String(value).trim() : fallback; } diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html index 8f7b7d5..d0fc383 100644 --- a/public/widgets/backup/backup.html +++ b/public/widgets/backup/backup.html @@ -121,7 +121,7 @@
diff --git a/src/components/IconSet.astro b/src/components/IconSet.astro index 05048eb..b8632e3 100644 --- a/src/components/IconSet.astro +++ b/src/components/IconSet.astro @@ -1,25 +1,15 @@ --- -import { readRepoFile } from '../lib/repo'; +import { iconSprite, loadIconSet } from '../lib/icon-set'; +import { SIDEBAR_ICON_IDS } from '../lib/sidebar-icons'; -const source = readRepoFile('ui/js/icon-set.js'); -let sprite = ''; -let ids: string[] = []; -if (source) { - const mod = await import(/* @vite-ignore */ `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); - let defs = ''; - let syms = ''; - ids = Object.keys(mod.ICONS); - for (const id of ids) { - const s = mod.symbol(id, mod.ICONS[id]); - defs += s.defs; - syms += s.sym; - } - sprite = ``; -} +const mod = await loadIconSet(); +const ids = mod ? Object.keys(mod.ICONS) : []; +/* The sidebar sprite defines these ids on every docs page. Repeating them duplicates ids. */ +const sprite = await iconSprite(ids.filter((id) => !SIDEBAR_ICON_IDS.includes(id))); --- { - source ? ( + mod ? (
{ids.map(id => ( diff --git a/src/components/Sidebar.astro b/src/components/Sidebar.astro new file mode 100644 index 0000000..7f6df3f --- /dev/null +++ b/src/components/Sidebar.astro @@ -0,0 +1,19 @@ +--- +import MobileMenuFooter from '@astrojs/starlight/components/MobileMenuFooter.astro'; +import SidebarPersister from '@astrojs/starlight/components/SidebarPersister.astro'; +import SidebarSublist from './SidebarSublist.astro'; +import { iconSprite } from '../lib/icon-set'; +import { SIDEBAR_ICON_IDS } from '../lib/sidebar-icons'; + +const { sidebar } = Astro.locals.starlightRoute; +const sprite = await iconSprite(SIDEBAR_ICON_IDS); +--- + + + + + + +
+ +
diff --git a/src/components/SidebarSublist.astro b/src/components/SidebarSublist.astro new file mode 100644 index 0000000..053f7ca --- /dev/null +++ b/src/components/SidebarSublist.astro @@ -0,0 +1,59 @@ +--- +import { Icon } from '@astrojs/starlight/components'; +import SidebarRestorePoint from '@astrojs/starlight/components/SidebarRestorePoint.astro'; +import { sidebarIcon } from '../lib/sidebar-icons'; + +type Entry = + | { type: 'link'; label: string; href: string; isCurrent: boolean; attrs: Record } + | { type: 'group'; label: string; entries: Entry[]; collapsed: boolean }; + +interface Props { + sublist: Entry[]; + nested?: boolean; +} + +const { sublist, nested } = Astro.props; +const hasCurrent = (entries: Entry[]): boolean => + entries.some((e) => (e.type === 'link' ? e.isCurrent : hasCurrent(e.entries))); +--- + +
    + { + sublist.map((entry) => { + const icon = sidebarIcon(entry); + return ( +
  • + {entry.type === 'link' ? ( + + {icon && ( + + )} + {entry.label} + + ) : ( +
    + + {icon && ( + + )} + {entry.label} + + + + +
    + )} +
  • + ); + }) + } +
diff --git a/src/content/docs/docs/badges.md b/src/content/docs/docs/badges.md index f855fe4..992e9c5 100644 --- a/src/content/docs/docs/badges.md +++ b/src/content/docs/docs/badges.md @@ -8,7 +8,7 @@ description: Add health checks, fixed labels and live values from any API to Sta A badge is the small pill on an app tile. Each app can have three kinds.
- The Badge section of an app: Health Check, Fixed Label and Live ActivityThe Badge section of an app: Health Check, Fixed Label and Live Activity + The Badge section of an app: Health Check, Fixed Label and Live ActivityThe Badge section of an app: Health Check, Fixed Label and Live Activity
## Health check @@ -37,7 +37,7 @@ Tick **Secret** on a header to keep its value on the server. Poll sets how often One API can feed up to five values. Press Add Label for each.
- Two labels, each with Value, Label Text, Color, Unit and Show FromTwo labels, each with Value, Label Text, Color, Unit and Show From + Two labels, each with Value, Label Text, Color, Unit and Show FromTwo labels, each with Value, Label Text, Color, Unit and Show From
- **Show From** keeps a value hidden below that count. diff --git a/src/content/docs/docs/contributing/design-system.mdx b/src/content/docs/docs/contributing/design-system.mdx index 1d9da0b..2cdd12a 100644 --- a/src/content/docs/docs/contributing/design-system.mdx +++ b/src/content/docs/docs/contributing/design-system.mdx @@ -44,20 +44,20 @@ Use the files as they are. Do not recolour, stretch or redraw them. diff --git a/src/content/docs/docs/customization.md b/src/content/docs/docs/customization.md index d4adb31..a56efd2 100644 --- a/src/content/docs/docs/customization.md +++ b/src/content/docs/docs/customization.md @@ -21,7 +21,7 @@ Browsers only allow Keep Screen Awake over HTTPS. On a plain HTTP address the to
- The Appearance section of the admin UIThe Appearance section of the admin UI + The Appearance section of the admin UIThe Appearance section of the admin UI
Appearance, in the admin UI.
diff --git a/src/content/docs/docs/index.md b/src/content/docs/docs/index.md index 4d883c7..963a110 100644 --- a/src/content/docs/docs/index.md +++ b/src/content/docs/docs/index.md @@ -5,7 +5,7 @@ description: Stackyard is a calm, self-hosted homelab dashboard for your apps, w Stackyard is a self-hosted dashboard for your homelab. Apps, folders, a few widgets and status badges, in one container. -The Stackyard dashboard, with widgets, app tiles, a folder and the dock. +The Stackyard dashboard, with widgets, app tiles, two folders and the dock. - **Calm.** A health badge shows only when something is wrong. - **Badges from any API.** Pick a value from a service's API response. No code. See [Badges](/docs/badges/). diff --git a/src/lib/icon-set.ts b/src/lib/icon-set.ts new file mode 100644 index 0000000..ad7e12b --- /dev/null +++ b/src/lib/icon-set.ts @@ -0,0 +1,54 @@ +import { readRepoFile } from './repo'; + +type Shape = Record; +type Icon = { shapes: Shape[]; gap?: number; inkMask?: boolean }; +type IconModule = { + ICONS: Record; + symbol: (id: string, icon: Icon) => { defs: string; sym: string }; + rect: (...a: unknown[]) => Shape; + bar: (...a: unknown[]) => Shape; + line: (...a: unknown[]) => Shape; +}; + +let loaded: Promise | undefined; + +export function loadIconSet(): Promise { + loaded ??= (async () => { + const source = readRepoFile('ui/js/icon-set.js'); + if (!source) return null; + return import(/* @vite-ignore */ `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + })(); + return loaded; +} + +/* Used only when the app checkout predates them. The build fails on a missing id. */ +function docIcons(m: IconModule): Record { + return { + start: { + inkMask: false, + shapes: [m.line('M12 2v9M8 7l4 4 4-4', 'accent'), m.rect(2, 14, 20, 8, 4), m.bar(5, 17, 6)], + }, + run: { + gap: 0, + shapes: [m.rect(2, 4, 20, 16, 4), m.line('M6 9l3 3-3 3', 'accent'), m.bar(12, 14, 6)], + }, + create: { + shapes: [m.rect(3, 5, 16, 16, 4), m.line('M19 3v4M17 5h4', 'accent'), m.bar(6, 10, 6), m.bar(6, 14, 10)], + }, + }; +} + +export async function iconSprite(ids: string[]): Promise { + const m = await loadIconSet(); + if (!m) return ''; + const all = { ...docIcons(m), ...m.ICONS }; + let defs = ''; + let syms = ''; + for (const id of new Set(ids)) { + if (!all[id]) throw new Error(`Unknown icon: ${id}`); + const s = m.symbol(id, all[id]); + defs += s.defs; + syms += s.sym; + } + return ``; +} diff --git a/src/lib/sidebar-icons.ts b/src/lib/sidebar-icons.ts new file mode 100644 index 0000000..d9d3162 --- /dev/null +++ b/src/lib/sidebar-icons.ts @@ -0,0 +1,35 @@ +/* Keys are sidebar group labels and link paths in astro.config.mjs. Renaming + either there drops the icon here. */ +const GROUPS: Record = { + 'Get started': 'start', + 'Build your dashboard': 'dashboard', + Widgets: 'widget', + 'Run Stackyard': 'run', + 'Create a widget': 'create', + Contributing: 'heart', + About: 'info', +}; + +const LINKS: Record = { + '/docs/settings-reference/': 'general', + '/docs/troubleshooting/': 'bug', + '/docs/widgets/': 'docs', + '/docs/widgets/backup/': 'backup', + '/docs/widgets/books/': 'books', + '/docs/widgets/clock/': 'clock', + '/docs/widgets/connections/': 'connections', + '/docs/widgets/custom/': 'edit', + '/docs/widgets/dashboard-switch/': 'switcher', + '/docs/widgets/disk-health/': 'disk', + '/docs/widgets/dns/': 'dns', + '/docs/widgets/github/': 'github', + '/docs/widgets/now-playing/': 'nowplaying', + '/docs/widgets/system-summary/': 'system', + '/docs/widgets/weather/': 'weather', +}; + +export const SIDEBAR_ICON_IDS = [...new Set([...Object.values(GROUPS), ...Object.values(LINKS)])]; + +export function sidebarIcon(entry: { type: string; label: string; href?: string }): string | undefined { + return entry.type === 'group' ? GROUPS[entry.label] : LINKS[entry.href ?? '']; +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 0c887b9..00847fc 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,6 +1,5 @@ --- import { readAppVersion } from '../lib/repo'; -import '../styles/fonts.css'; const version = readAppVersion(); @@ -10,14 +9,14 @@ const description = const site = Astro.site?.href.replace(/\/$/, '') ?? ''; const canonical = `${site}/`; -const ogImage = `${site}/img/og-64722d2b.jpg`; +const ogImage = `${site}/img/og-48fdebbd.jpg`; const publisher = { '@type': 'Organization', '@id': `${site}/#organization`, name: 'Stackyard', url: `${site}/`, - logo: `${site}/img/stackyard-wordmark-dark.svg`, + logo: `${site}/img/stackyard-wordmark-dark-4680834d.svg`, sameAs: ['https://github.com/SandObserver/stackyard'], }; @@ -86,13 +85,6 @@ const schema = {