Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed public/img/admin/app-edit-badge-8cf6ea34.png
Binary file not shown.
Binary file added public/img/admin/app-edit-badge-b0148914.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/img/admin/app-edit-badge-dark-48e1e5f8.png
Binary file not shown.
Binary file added public/img/admin/app-edit-badge-dark-d272ba44.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/img/admin/app-edit-labels-27443940.png
Binary file not shown.
Binary file added public/img/admin/app-edit-labels-3cce9d48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/img/admin/app-edit-labels-dark-4998668a.png
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/img/admin/appearance-d7bb5b62.png
Binary file not shown.
Binary file added public/img/admin/appearance-da7cfb3d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/img/admin/appearance-dark-d382dd9a.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/img/admin/appearance-dark-e18f071b.png
Binary file not shown.
Binary file removed public/img/dashboard-c1303e4c.jpg
Binary file not shown.
Binary file added public/img/dashboard-c6746175.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion public/js/admin-app-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion public/js/admin-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 0 additions & 15 deletions public/js/admin-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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() || '',
Expand Down
10 changes: 5 additions & 5 deletions public/js/admin-widget-form.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -107,7 +108,7 @@ function _renderWidgetForm(body) {
scard.className = 'grp';
setHtml(
scard,
html`<div class="row tile-row"><div class="tile-grp tile-grp-left">${_sizeOpts.map(s => html`<button type="button" class="tile-opt${s === state._wsize ? ' on' : ''}" data-size="${s}"><span class="tile-ico"><svg width="26" height="26" viewBox="0 0 24 24" aria-hidden="true">${raw(SIZE_ICONS[s] || SIZE_ICONS.medium)}</svg></span><span class="tile-cap">${sizeLabel(s)}</span></button>`)}</div></div>`,
html`<div class="row tile-row"><div class="tile-grp">${_sizeOpts.map(s => html`<button type="button" class="tile-opt${s === state._wsize ? ' on' : ''}" aria-pressed="${String(s === state._wsize)}" data-size="${s}"><span class="tile-ico"><svg width="26" height="26" viewBox="0 0 24 24" aria-hidden="true">${raw(SIZE_ICONS[s] || SIZE_ICONS.medium)}</svg></span><span class="tile-cap">${sizeLabel(s)}</span></button>`)}</div></div>`,
);
body.appendChild(scard);
qa('.tile-opt', scard).forEach(b =>
Expand Down Expand Up @@ -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',
Expand Down
25 changes: 6 additions & 19 deletions public/js/admin.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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`<span class="rl">${t('type.addNew')}</span>`);
const grpTiles = document.createElement('div');
grpTiles.className = 'tile-grp';
const labels = typeLabels();
Expand Down Expand Up @@ -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' });

Expand Down
56 changes: 56 additions & 0 deletions public/js/dashboard-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 26 additions & 30 deletions public/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand All @@ -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. */
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
60 changes: 60 additions & 0 deletions public/js/glass-rim.js
Original file line number Diff line number Diff line change
@@ -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();
}
Loading