diff --git a/public/img/admin/app-edit-badge-8cf6ea34.png b/public/img/admin/app-edit-badge-8cf6ea34.png deleted file mode 100644 index 1583dbb..0000000 Binary files a/public/img/admin/app-edit-badge-8cf6ea34.png and /dev/null differ diff --git a/public/img/admin/app-edit-badge-b0148914.png b/public/img/admin/app-edit-badge-b0148914.png new file mode 100644 index 0000000..8fb7f8d Binary files /dev/null and b/public/img/admin/app-edit-badge-b0148914.png differ diff --git a/public/img/admin/app-edit-badge-dark-48e1e5f8.png b/public/img/admin/app-edit-badge-dark-48e1e5f8.png deleted file mode 100644 index 4879446..0000000 Binary files a/public/img/admin/app-edit-badge-dark-48e1e5f8.png and /dev/null differ diff --git a/public/img/admin/app-edit-badge-dark-d272ba44.png b/public/img/admin/app-edit-badge-dark-d272ba44.png new file mode 100644 index 0000000..55c9136 Binary files /dev/null and b/public/img/admin/app-edit-badge-dark-d272ba44.png differ diff --git a/public/img/admin/app-edit-labels-27443940.png b/public/img/admin/app-edit-labels-27443940.png deleted file mode 100644 index 1562f18..0000000 Binary files a/public/img/admin/app-edit-labels-27443940.png and /dev/null differ diff --git a/public/img/admin/app-edit-labels-3cce9d48.png b/public/img/admin/app-edit-labels-3cce9d48.png new file mode 100644 index 0000000..a3abccd Binary files /dev/null and b/public/img/admin/app-edit-labels-3cce9d48.png differ diff --git a/public/img/admin/app-edit-labels-dark-4998668a.png b/public/img/admin/app-edit-labels-dark-4998668a.png deleted file mode 100644 index 1d7f3bb..0000000 Binary files a/public/img/admin/app-edit-labels-dark-4998668a.png and /dev/null differ diff --git a/public/img/admin/app-edit-labels-dark-7729b939.png b/public/img/admin/app-edit-labels-dark-7729b939.png new file mode 100644 index 0000000..e6114f8 Binary files /dev/null and b/public/img/admin/app-edit-labels-dark-7729b939.png differ diff --git a/public/img/admin/appearance-d7bb5b62.png b/public/img/admin/appearance-d7bb5b62.png deleted file mode 100644 index db82cb2..0000000 Binary files a/public/img/admin/appearance-d7bb5b62.png and /dev/null differ diff --git a/public/img/admin/appearance-da7cfb3d.png b/public/img/admin/appearance-da7cfb3d.png new file mode 100644 index 0000000..ba004aa Binary files /dev/null and b/public/img/admin/appearance-da7cfb3d.png differ diff --git a/public/img/admin/appearance-dark-d382dd9a.png b/public/img/admin/appearance-dark-d382dd9a.png new file mode 100644 index 0000000..8b0c74c Binary files /dev/null and b/public/img/admin/appearance-dark-d382dd9a.png differ diff --git a/public/img/admin/appearance-dark-e18f071b.png b/public/img/admin/appearance-dark-e18f071b.png deleted file mode 100644 index a056259..0000000 Binary files a/public/img/admin/appearance-dark-e18f071b.png and /dev/null differ diff --git a/public/img/dashboard-c1303e4c.jpg b/public/img/dashboard-c1303e4c.jpg deleted file mode 100644 index 152c0c7..0000000 Binary files a/public/img/dashboard-c1303e4c.jpg and /dev/null differ diff --git a/public/img/dashboard-c6746175.jpg b/public/img/dashboard-c6746175.jpg new file mode 100644 index 0000000..fe3fdc3 Binary files /dev/null and b/public/img/dashboard-c6746175.jpg differ diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index dac5ad6..ff74f44 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -22,7 +22,7 @@ import { setTogDisabled, swapContent, } from '/js/admin-shared.js?v=fd784739'; -import { createListbox } from '/js/listbox.js?v=a67e9c98'; +import { createListbox } from '/js/listbox.js?v=bedd1104'; import { MAX_LABELS } from '/js/badge-logic.js?v=b3c8b6c2'; import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=837961fa'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; diff --git a/public/js/admin-list.js b/public/js/admin-list.js index 17d0f06..be890c2 100644 --- a/public/js/admin-list.js +++ b/public/js/admin-list.js @@ -19,7 +19,7 @@ import { paintIcon } from '/js/admin-shared.js?v=fd784739'; import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=70cbc405'; 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=3b1b3546'; +import { sizeLabel } from '/js/admin-widget-form.js?v=3e1f15dd'; import { widgetGlyph } from '/js/widget-glyphs.js?v=12b0a947'; /** @type {{ openModal: (idx: number|null) => void, diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index 12634d2..23bd7c3 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -36,8 +36,6 @@ const _shown = id => { out. */ const readServerForm = () => JSON.stringify([ - _shown('ie-title-v'), - _shown('ie-desc-v'), _val('srv-ip'), _val('srv-docker-en'), _val('srv-socket'), @@ -185,12 +183,6 @@ export function loadSettings(c) { node.classList.add('is-ph'); } }; - _sv('ie-title-v', s.title || 'Stackyard', 'Stackyard'); - _sv( - 'ie-desc-v', - s.description || 'Stackyard · self-hosted homelab dashboard', - 'Stackyard · self-hosted homelab dashboard', - ); _sv('ie-ip-v', s.server?.hostIp, '192.168.1.100'); _sv('ie-socket-v', s.server?.socketProxyUrl, 'http://socket-proxy:2375'); _sv('ie-pw-v', '', t('common.notSet')); /* set below after auth check */ @@ -439,13 +431,6 @@ async function saveServer() { const prevLang = c.settings.language || 'en'; const dockerEnabled = inp('srv-docker-en')?.checked || false; const socketUrl = inp('srv-socket')?.value?.trim() || ''; - /* A greyed placeholder (.is-ph) means empty, so it is not saved. */ - const titleEl = el('ie-title-v'); - const descEl = el('ie-desc-v'); - const titleV = titleEl && !titleEl.classList.contains('is-ph') ? titleEl.textContent.trim() : ''; - const descV = descEl && !descEl.classList.contains('is-ph') ? descEl.textContent.trim() : ''; - if (titleV) c.settings.title = titleV; - if (descV) c.settings.description = descV; c.settings.server = { ...c.settings.server, hostIp: inp('srv-ip')?.value?.trim() || '', diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 91e2b4e..a056cc9 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,7 +1,7 @@ import { state } from '/js/admin-state.js?v=5a5d655f'; import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=fd784739'; -import { createListbox } from '/js/listbox.js?v=a67e9c98'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=ad9e05ee'; +import { createListbox } from '/js/listbox.js?v=bedd1104'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=40e30a39'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=69e57d35'; import { t } from '/js/i18n.js?v=1f1ea9c1'; @@ -55,7 +55,8 @@ function _renderWidgetForm(body) { state._wAutoCfg = Object.assign({}, state._wAutoCfg, state._autoForm.getValues()); } state._autoForm = null; - body.replaceChildren(); + const addNew = body.querySelector(':scope > .add-new-card'); + body.replaceChildren(...(addNew ? [addNew] : [])); const typeList = [...Object.values(state._widgetReg).map(w => [w.name, w.label]), ['custom', 'Custom']].sort((a, b) => a[1].localeCompare(b[1]), @@ -107,7 +108,7 @@ function _renderWidgetForm(body) { scard.className = 'grp'; setHtml( scard, - html`
${_sizeOpts.map(s => html``)}
`, + html`
${_sizeOpts.map(s => html``)}
`, ); body.appendChild(scard); qa('.tile-opt', scard).forEach(b => @@ -237,7 +238,6 @@ function _renderCustomConfig(body) { const ri = parseInt(qi('#if-refresh', adv).value, 10); state._iframeOpts.refreshInterval = ri && ri >= 250 ? ri : undefined; }; - q('#if-referrer', adv).onchange = sync; q('#if-fs', adv).onchange = sync; initInlineEdit('if-allow-row', 'if-allow', { placeholder: 'autoplay; fullscreen', diff --git a/public/js/admin.js b/public/js/admin.js index 42c4d87..1de6f87 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,6 +1,6 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=a53ac0e8'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=47949906'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=c70fb722'; -import { initList, render, syncFilterUI } from '/js/admin-list.js?v=b0923a32'; +import { initList, render, syncFilterUI } from '/js/admin-list.js?v=94b420e9'; import { resolveAdminSection } from '/js/admin-logic.js?v=69e57d35'; import { buildAppItem, @@ -10,13 +10,13 @@ import { snapshotItems, upsertItem, } from '/js/admin-save-logic.js?v=4f71ef6c'; -import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=079a3d2d'; +import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=2d1a8a29'; import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=fd784739'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=3b1b3546'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=3e1f15dd'; 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=a67e9c98'; +import { createListbox } from '/js/listbox.js?v=bedd1104'; 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'; @@ -202,10 +202,9 @@ const typeLabels = () => ({ app: t('type.app'), widget: t('type.widget'), folder function buildAddNewCard() { const grp = document.createElement('div'); - grp.className = 'grp'; + grp.className = 'grp add-new-card'; const row = document.createElement('div'); row.className = 'row tile-row'; - setHtml(row, html`${t('type.addNew')}`); const grpTiles = document.createElement('div'); grpTiles.className = 'tile-grp'; const labels = typeLabels(); @@ -634,18 +633,6 @@ function initNav() { } function initAllInlineEdits() { - initInlineEdit('ie-title', 'ie-input', { - placeholder: 'Stackyard', - onCommit(v) { - el('ie-title-v').textContent = v || 'Stackyard'; - }, - }); - - const descInp = document.createElement('input'); - descInp.id = 'ie-desc-input'; - document.body.appendChild(descInp); - initInlineEdit('ie-desc', 'ie-desc-input', { placeholder: 'Stackyard · self-hosted homelab dashboard' }); - initInlineEdit('ie-ip', 'srv-ip', { placeholder: '192.168.1.100' }); initInlineEdit('ie-socket', 'srv-socket', { placeholder: 'http://socket-proxy:2375' }); diff --git a/public/js/dashboard-logic.js b/public/js/dashboard-logic.js index 6677485..f6b7474 100644 --- a/public/js/dashboard-logic.js +++ b/public/js/dashboard-logic.js @@ -32,6 +32,62 @@ export function desktopCols(avail) { return Math.max(DESKTOP_COL.min, Math.min(DESKTOP_COL.max, fit)); } +/** Must match the sparse auto-placement of `.grid`, or a tile lands below the page. + + @template T + @param {T[]} tiles in display order + @param {(tile: T) => [number, number]} span columns and rows a tile takes + @param {number} cols @param {number} rows + @returns {T[][]} */ +export function desktopPages(tiles, span, cols, rows) { + /** @type {T[][]} */ + const pages = []; + /** @type {T[]} */ + let cur = []; + /** @type {boolean[][]} */ + let taken = []; + let r = 0, + c = 0; + const free = ( + /** @type {number} */ row, + /** @type {number} */ col, + /** @type {number} */ w, + /** @type {number} */ h, + ) => { + for (let y = row; y < row + h; y++) for (let x = col; x < col + w; x++) if (taken[y]?.[x]) return false; + return true; + }; + for (const tile of tiles) { + const [sw, sh] = span(tile); + const w = Math.min(Math.max(1, sw), cols); + const h = Math.max(1, sh); + const place = () => { + for (;;) { + for (; c + w <= cols; c++) if (free(r, c, w, h)) return r; + r++; + c = 0; + } + }; + let row = place(); + if (row + h > rows && cur.length) { + pages.push(cur); + cur = []; + taken = []; + r = 0; + c = 0; + row = place(); + } + for (let y = row; y < row + h; y++) { + taken[y] ??= []; + for (let x = c; x < c + w; x++) taken[y][x] = true; + } + c += w; + cur.push(tile); + } + if (cur.length) pages.push(cur); + return pages; +} + export function restorePage(stored, totalPages) { const n = Number(stored); if (stored == null || stored === '' || !Number.isInteger(n) || n < 0) return 0; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 7fe5f6a..68a4d9f 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -4,11 +4,10 @@ import { WIDGET_DESIGN, WIDGET_COLS, WIDGET_ROWS, - WIDGET_COST, widgetSrc, cardPreset, uniqueTitle, -} from '/js/widget-types.js?v=a1b61636'; +} from '/js/widget-types.js?v=d36b0153'; import { el, isDashboardEmpty, @@ -39,11 +38,18 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=8c5a8dba'; +} from '/js/ui.js?v=b530cbb6'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=b3c8b6c2'; import { formatNumber } from '/js/format-number.js?v=4a5ccef4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3'; -import { configChanged, desktopCols, landingAfterSetup, restorePage } from '/js/dashboard-logic.js?v=a8f759ed'; +import { observeGlass } from '/js/glass-rim.js?v=3faec233'; +import { + configChanged, + desktopCols, + desktopPages, + landingAfterSetup, + restorePage, +} from '/js/dashboard-logic.js?v=0d519f8b'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; import { jitter } from '/js/jitter.js?v=4eeef4c9'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; @@ -63,7 +69,6 @@ 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; -const wCost = { d: WIDGET_COST.desktop, m: WIDGET_COST.mobile }; /* The design values the stylesheet's --tile-h and --row-gap ratios were derived from. Read gridMetrics() for the live sizes; these are only the fallback. */ @@ -98,12 +103,11 @@ let gm = { tile: DESIGN_TILE, rowGap: DESIGN_ROW_GAP, cols: 6 }; which sit above the dock. @param {boolean} hasDock */ -function desktopSlots(hasDock) { +function desktopRows(hasDock) { const ih = innerHeight; const top = Math.min(70, Math.max(44, ih * 0.04)); const bottom = hasDock ? 204 : 68; - const rows = Math.max(1, Math.min(4, Math.floor((ih - top - bottom + gm.rowGap) / (gm.tile + gm.rowGap)))); - return gm.cols * rows; + return Math.max(1, Math.min(4, Math.floor((ih - top - bottom + gm.rowGap) / (gm.tile + gm.rowGap)))); } const CB = { spotOpen: null, spotClose: null, mobPillBump: null }; @@ -282,27 +286,18 @@ function paginate() { .flatMap(f => f.children || []) .map(String), ); - const budget = desktopSlots(items.some(i => i.type === 'app' && i.dock && !i.hidden)); + const rows = desktopRows(items.some(i => i.type === 'app' && i.dock && !i.hidden)); const bare = isDashboardEmpty(items); - const pages = []; - let cur = [], - used = 0; - for (const item of items) { - if (bare && item.system) continue; - if (item.dock) continue; - if (item.hidden) continue; - if (inFolder.has(String(item.id))) continue; - const cost = item.type === 'widget' ? wCost[pl][item.widgetSize || 'medium'] : 1; - if (used + cost > budget && cur.length) { - pages.push([...cur]); - cur = []; - used = 0; - } - cur.push(item); - used += cost; - } - if (cur.length) pages.push(cur); - return pages; + const tiles = items.filter( + item => !(bare && item.system) && !item.dock && !item.hidden && !inFolder.has(String(item.id)), + ); + /** @returns {[number, number]} */ + const span = item => { + if (item.type !== 'widget') return [1, 1]; + const sz = item.widgetSize || 'medium'; + return [wCols[pl][sz], wRows[pl][sz]]; + }; + return desktopPages(tiles, span, gm.cols, rows); } function mkIcon(item) { @@ -424,6 +419,7 @@ function buildDesktop() { dk.replaceChildren(); dock.forEach(item => dk.appendChild(mkDock(item))); dk.hidden = !dock.length; + observeGlass(dk, 45, 0.2); } /* Every page is mounted at once, so widgets the user has swiped away from keep @@ -998,7 +994,7 @@ async function boot() { pixel: a rebuild tears down and remounts every widget iframe. */ const hasDock = () => items.some(i => i.type === 'app' && i.dock && !i.hidden); let _dz, - _slots = desktopSlots(hasDock()); + _slots = desktopRows(hasDock()) * gm.cols; /* The phone layout rebuilds on a width change only. A phone keyboard changes the height, and a rebuild then would close it. */ let _rs; @@ -1014,7 +1010,7 @@ async function boot() { if (MOB) return; gm = gridMetrics(); document.documentElement.style.setProperty('--cols', String(gm.cols)); - const slots = desktopSlots(hasDock()); + const slots = desktopRows(hasDock()) * gm.cols; if (slots === _slots) return; _slots = slots; buildDesktop(); diff --git a/public/js/glass-rim.js b/public/js/glass-rim.js new file mode 100644 index 0000000..06a18a6 --- /dev/null +++ b/public/js/glass-rim.js @@ -0,0 +1,60 @@ +import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; + +const NS = 'http://www.w3.org/2000/svg'; +let seq = 0; +const observers = new WeakMap(); + +function svgEl(tag, attrs) { + const el = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)); + return el; +} + +/** Clip the host to the same path `d`, or half of the stroke paints outside it. + @param {number} w @param {number} h @param {string} d @returns {SVGSVGElement} */ +export function mkGlassRim(w, h, d) { + const id = `glass-rim-${++seq}`; + const edge = Math.min(0.5, 14 / h); + const svg = document.createElementNS(NS, 'svg'); + svg.setAttribute('class', 'glass-rim'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('viewBox', `0 0 ${w} ${h}`); + const line = svgEl('linearGradient', { id, gradientUnits: 'userSpaceOnUse', x1: 0, y1: 0, x2: 0, y2: h }); + for (const [offset, cls] of [ + [0, 'glass-rim-light'], + [edge, 'glass-rim-dark'], + [1 - edge, 'glass-rim-dark'], + [1, 'glass-rim-light'], + ]) { + line.appendChild(svgEl('stop', { offset, class: cls })); + } + const defs = svgEl('defs', {}); + defs.appendChild(line); + svg.appendChild(defs); + svg.appendChild(svgEl('path', { d, stroke: `url(#${id})`, 'stroke-width': 1.5 })); + return svg; +} + +/** Glass behind a host, redrawn on every resize. + The host must be positioned and set isolation:isolate, or the layer paints over its content. + @param {HTMLElement} host @param {number} radius @param {number} smoothing + @returns {() => void} stops the redraws */ +export function observeGlass(host, radius, smoothing) { + observers.get(host)?.disconnect(); + const layer = document.createElement('div'); + layer.className = 'glass-layer glass-surface'; + host.prepend(layer); + const draw = () => { + const w = host.offsetWidth; + const h = host.offsetHeight; + if (!w || !h) return; + const d = smoothRectPath(w, h, radius, smoothing); + layer.style.clipPath = `path('${d}')`; + layer.replaceChildren(mkGlassRim(w, h, d)); + }; + draw(); + const ro = new ResizeObserver(draw); + ro.observe(host); + observers.set(host, ro); + return () => ro.disconnect(); +} diff --git a/public/js/listbox.js b/public/js/listbox.js index 23ea3a9..f325e1d 100644 --- a/public/js/listbox.js +++ b/public/js/listbox.js @@ -198,8 +198,9 @@ export function createListbox( document.addEventListener( 'click', e => { - const t = /** @type {Node} */ (e.target); - if (!dd.contains(t) && !list.contains(t)) close(); + /* Read the path, not the target. A pick redraws the list and detaches the target. */ + const path = e.composedPath(); + if (!path.includes(dd) && !path.includes(list)) close(); }, { signal: outside.signal }, ); diff --git a/public/js/smooth-corner.js b/public/js/smooth-corner.js new file mode 100644 index 0000000..1e390da --- /dev/null +++ b/public/js/smooth-corner.js @@ -0,0 +1,42 @@ +const rad = deg => (deg * Math.PI) / 180; +const n = v => Math.round(v * 100) / 100; + +/** A rounded rectangle whose corners ease into the edge. `smoothing` 0 is a plain arc. + @param {number} w @param {number} h @param {number} radius @param {number} smoothing + @returns {string} SVG path data, also valid inside CSS path() */ +export function smoothRectPath(w, h, radius, smoothing) { + const half = Math.min(w, h) / 2; + const r = Math.min(radius, half); + const s = Math.max(0, Math.min(smoothing, half / r - 1)); + const p = Math.min((1 + s) * r, half); + const arc = 90 * (1 - s); + const arcLen = Math.sin(rad(arc / 2)) * r * Math.SQRT2; + const alpha = (90 - arc) / 2; + const beta = 45 * s; + const c = r * Math.tan(rad(alpha / 2)) * Math.cos(rad(beta)); + const d = c * Math.tan(rad(beta)); + const b = (p - arcLen - c - d) / 3; + const a = 2 * b; + const k = a + b + c; + + return [ + `M${n(p)} 0`, + `L${n(w - p)} 0`, + `C${n(w - p + a)} 0 ${n(w - p + a + b)} 0 ${n(w - p + k)} ${n(d)}`, + `A${n(r)} ${n(r)} 0 0 1 ${n(w - d)} ${n(p - k)}`, + `C${n(w)} ${n(p - a - b)} ${n(w)} ${n(p - a)} ${n(w)} ${n(p)}`, + `L${n(w)} ${n(h - p)}`, + `C${n(w)} ${n(h - p + a)} ${n(w)} ${n(h - p + a + b)} ${n(w - d)} ${n(h - p + k)}`, + `A${n(r)} ${n(r)} 0 0 1 ${n(w - p + k)} ${n(h - d)}`, + `C${n(w - p + a + b)} ${n(h)} ${n(w - p + a)} ${n(h)} ${n(w - p)} ${n(h)}`, + `L${n(p)} ${n(h)}`, + `C${n(p - a)} ${n(h)} ${n(p - a - b)} ${n(h)} ${n(p - k)} ${n(h - d)}`, + `A${n(r)} ${n(r)} 0 0 1 ${n(d)} ${n(h - p + k)}`, + `C0 ${n(h - p + a + b)} 0 ${n(h - p + a)} 0 ${n(h - p)}`, + `L0 ${n(p)}`, + `C0 ${n(p - a)} 0 ${n(p - a - b)} ${n(d)} ${n(p - k)}`, + `A${n(r)} ${n(r)} 0 0 1 ${n(p - k)} ${n(d)}`, + `C${n(p - a - b)} 0 ${n(p - a)} 0 ${n(p)} 0`, + 'Z', + ].join(''); +} diff --git a/public/js/ui.js b/public/js/ui.js index 1aae42b..a85705b 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,5 +1,5 @@ import { iconChain } from '/js/icons.js?v=9c8c550c'; -import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=a1b61636'; +import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=d36b0153'; import { mk, clr, @@ -17,6 +17,10 @@ import { 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 { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; +import { mkGlassRim, observeGlass } from '/js/glass-rim.js?v=3faec233'; + +const PHONE_W = 393; let _state = null; export function initUI(state) { @@ -104,7 +108,7 @@ function mkMiniIcon(child, pointerEvents) { /** The miniature icon grid a folder tile shows. @param {number} size */ export function mkFolderGlyph(folder, size) { const wrap = mk('div'); - wrap.className = 'dyn-fold-wrap'; + wrap.className = 'dyn-fold-wrap glass-surface'; css(wrap, { '--br': Math.round(size * 0.24) + 'px', '--gap': Math.round(size * 0.04) + 'px', @@ -139,10 +143,8 @@ export function mkFolder(item) { box.className = 'dyn-folder-box'; css(box, { '--iw': iw + 'px' }); const wrap = mk('div'); - wrap.className = 'folder-icon-grid'; - const g = mk('div'); - g.className = 'folder-icon-grid-sheen'; - wrap.appendChild(g); + wrap.className = 'folder-icon-grid glass-surface'; + wrap.appendChild(mkGlassRim(iw, iw, smoothRectPath(iw, iw, 16, 0))); (item.children || []) .slice(0, 9) .map(id => items().find(i => i.id === id)) @@ -224,7 +226,9 @@ export function openFolderDesktop(folder) { } /* Escape and the tile's own toggle arrive here alike, and a badge registered to an element that has gone keeps the dashboard repainting it. */ + let stopGlass = () => {}; ov.addEventListener('close', () => { + stopGlass(); registeredBadges.forEach(el => BEL().forEach((_, id) => bunreg(id, el))); ov.remove(); folderOverlay = null; @@ -242,6 +246,7 @@ export function openFolderDesktop(folder) { tiles under the scrim were reachable by Tab and by a screen reader. */ ov.showModal(); ov.focus(); + stopGlass = observeGlass(box, 28, 0.2); } function mFolder(item, cw, rh, isz, ir, im, sc) { @@ -306,9 +311,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { css(box, { '--sz': eff + 'px' }); box.style.pointerEvents = 'none'; const wrap = mkFolderGlyph(item, eff); - const sheen = mk('div'); - sheen.className = 'dyn-fold-sheen'; - wrap.prepend(sheen); + wrap.appendChild(mkGlassRim(eff, eff, smoothRectPath(eff, eff, Math.round(eff * 0.24), 0))); box.appendChild(wrap); const fb_ = mk('div'); fb_.className = 'badge'; @@ -402,7 +405,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { setUserText(titleEl, folder.label || t('type.folder')); const box = mk('div'); - box.className = 'folder-box-mobile dyn-box-mob'; + box.className = 'dyn-box-mob glass-surface'; css(box, { '--left': boxLeft + 'px', '--bw': boxW + 'px', @@ -413,6 +416,9 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { '--ph': padH - badgeOvh + 'px', '--pb': padVB - badgeOvh + 'px', }); + const boxD = smoothRectPath(boxW, boxH, boxR, 0.2); + box.style.clipPath = `path('${boxD}')`; + box.appendChild(mkGlassRim(boxW, boxH, boxD)); const clipW = mk('div'); clipW.className = 'dyn-clip'; @@ -782,16 +788,14 @@ export function buildMobile() { const dockIr = Math.round(dockIconSz * 0.225), dockIm = Math.round(dockIconSz * 0.64); const dockGap = Math.round(9 * sc); - /* Sized to what it holds, not to the window. The grid gains columns on a wide - screen while the dock keeps four icons, and a stretched bar reads as empty - rather than as a dock. */ - const dockContentW = dock.length - ? dock.length * dockIconSz + (dock.length - 1) * Math.round(22 * sc) + dockPad * 2 - : maxDockW; - const dockW = Math.min(maxDockW, dockContentW); + const dockW = Math.min(maxDockW, Math.round((PHONE_W - 18) * sc)); + const spare = dockW - dockPad * 2 - dock.length * dockIconSz; + const maxIconGap = Math.round(22 * sc); + const dockIconGap = dock.length > 1 ? Math.max(0, Math.min(maxIconGap, Math.floor(spare / (dock.length - 1)))) : 0; 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;border-radius:${Math.round(44 * sc)}px;z-index:400;`; + 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); 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/widget-config-form.js b/public/js/widget-config-form.js index 8e99c9e..9b2b7d8 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -4,7 +4,7 @@ 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=fd784739'; -import { createListbox } from '/js/listbox.js?v=a67e9c98'; +import { createListbox } from '/js/listbox.js?v=bedd1104'; import { renderColorControl } from '/js/admin-color-control.js?v=837961fa'; import { seedCarried, diff --git a/public/js/widget-types.js b/public/js/widget-types.js index 1a8e252..6b64427 100644 --- a/public/js/widget-types.js +++ b/public/js/widget-types.js @@ -13,10 +13,6 @@ export const WIDGET_ROWS = { desktop: { small: 0, medium: 0, large: 2, xlarge: 3 }, mobile: { small: 2, medium: 2, large: 4, xlarge: 6 }, }; -export const WIDGET_COST = { - desktop: { small: 1, medium: 2, large: 4, xlarge: 6 }, - mobile: { small: 4, medium: 8, large: 16, xlarge: 24 }, -}; /* The iframe URL, from the manifest entry in `reg`. The cache version is hashed from file content at release, never maintained by hand. */ diff --git a/src/content/docs/docs/badges.md b/src/content/docs/docs/badges.md index b642e3e..98eaa65 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 @@ -35,7 +35,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/customization.md b/src/content/docs/docs/customization.md index 05be575..478da9e 100644 --- a/src/content/docs/docs/customization.md +++ b/src/content/docs/docs/customization.md @@ -19,7 +19,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 f9cac65..4adc261 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, a folder 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/content/docs/docs/settings-reference.md b/src/content/docs/docs/settings-reference.md index ebe7f87..84dc741 100644 --- a/src/content/docs/docs/settings-reference.md +++ b/src/content/docs/docs/settings-reference.md @@ -15,8 +15,6 @@ At the top, before any group: | Setting | What it does | | --- | --- | -| Title | The name of this Stackyard instance. | -| Description | Free text shown alongside the title. | | Host IP | Your server's own address. Setting it lets badges and widgets reach services on that IP, which the SSRF guard would otherwise block. See [Security](/docs/security/). | ### Language