diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index 64a482f..7ab7132 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=d949e985'; +import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=4c1189b9'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconName } from '/js/icons.js?v=69c2b9bd'; import { state } from '/js/admin-state.js?v=7d68e98e'; @@ -19,9 +19,9 @@ import { initInlineEdit, setTogDisabled, wireChecklist, -} from '/js/admin-shared.js?v=132c869f'; +} from '/js/admin-shared.js?v=d7dd8b71'; import { MAX_LABELS } from '/js/badge-logic.js?v=b3c8b6c2'; -import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=3a61c02f'; +import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=f638b750'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; export function buildFolderForm(body, item) { diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index acfe25b..d4458d7 100644 --- a/public/js/admin-auth.js +++ b/public/js/admin-auth.js @@ -1,7 +1,7 @@ -import { ag, ap } from '/js/admin-shared.js?v=132c869f'; +import { ag, ap } from '/js/admin-shared.js?v=d7dd8b71'; import { t } from '/js/i18n.js?v=e644a5c5'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; -import { el, inp as inpById, qa } from '/js/utils.js?v=d949e985'; +import { el, inp as inpById, qa } from '/js/utils.js?v=4c1189b9'; export async function checkAuth(onLogin) { try { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index a6f5b30..b935d08 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=132c869f'; +import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=d7dd8b71'; import { t } from '/js/i18n.js?v=e644a5c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=d949e985'; +import { qa, q } from '/js/utils.js?v=4c1189b9'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; export const BADGE_DEFAULT = '#1e6ef4'; diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js index c8edb49..14ded00 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=7d68e98e'; -import { snapshotItems } from '/js/admin-save-logic.js?v=48a9e055'; -import { qa } from '/js/utils.js?v=d949e985'; +import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c'; +import { qa } from '/js/utils.js?v=4c1189b9'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/public/js/admin-list.js b/public/js/admin-list.js index 9207a00..dbd7788 100644 --- a/public/js/admin-list.js +++ b/public/js/admin-list.js @@ -12,14 +12,14 @@ reads and writes them too. */ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e'; -import { snapshotItems } from '/js/admin-save-logic.js?v=48a9e055'; +import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c'; import { reorderItems } from '/js/admin-logic.js?v=dcf7c37d'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=f9adcc10'; -import { paintIcon } from '/js/admin-shared.js?v=132c869f'; -import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=d949e985'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=971d097b'; +import { paintIcon } from '/js/admin-shared.js?v=d7dd8b71'; +import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=4c1189b9'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=e644a5c5'; -import { sizeLabel } from '/js/admin-widget-form.js?v=0ca20e21'; +import { sizeLabel } from '/js/admin-widget-form.js?v=cc1dfdec'; import { widgetGlyph } from '/js/widget-glyphs.js?v=12b0a947'; /** @type {{ openModal: (idx: number|null) => void, diff --git a/public/js/admin-save-logic.js b/public/js/admin-save-logic.js index 961f171..b1650d8 100644 --- a/public/js/admin-save-logic.js +++ b/public/js/admin-save-logic.js @@ -44,7 +44,7 @@ export function newItemId(label, fallback = 'item', taken = []) { /** @param {any[]} items @returns {any[]} */ export function snapshotItems(items) { - return Array.isArray(items) ? JSON.parse(JSON.stringify(items)) : []; + return Array.isArray(items) ? structuredClone(items) : []; } /** Run `write` and undo the local change when it did not reach the server. diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index cb6c88f..55bfc6e 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,9 +1,9 @@ -import { toast, ag, ap } from '/js/admin-shared.js?v=132c869f'; +import { toast, ag, ap } from '/js/admin-shared.js?v=d7dd8b71'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { t } from '/js/i18n.js?v=e644a5c5'; import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=dcf7c37d'; import { confirmText } from '/js/modal.js?v=11fa1eff'; -import { el, inp, q, qa, setUserText } from '/js/utils.js?v=d949e985'; +import { el, inp, q, qa, setUserText } from '/js/utils.js?v=4c1189b9'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ let _passwordSet = false; diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index 9678e50..5f3ab1b 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,6 +1,6 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=dcf7c37d'; -import { el, qa, q } from '/js/utils.js?v=d949e985'; +import { el, qa, q } from '/js/utils.js?v=4c1189b9'; import { t } from '/js/i18n.js?v=e644a5c5'; import { iconChain } from '/js/icons.js?v=69c2b9bd'; diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 1734981..97c6ffe 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,10 +1,10 @@ import { state } from '/js/admin-state.js?v=7d68e98e'; -import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=132c869f'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=0f95ec4c'; +import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=d7dd8b71'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=02918336'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=dcf7c37d'; import { t } from '/js/i18n.js?v=e644a5c5'; -import { q, qi, qa } from '/js/utils.js?v=d949e985'; +import { q, qi, qa } from '/js/utils.js?v=4c1189b9'; const SIZE_ICONS = { small: diff --git a/public/js/admin.js b/public/js/admin.js index de4b999..89fde1e 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=5702feef'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9fc96f28'; -import { initList, render, syncFilterUI } from '/js/admin-list.js?v=0a1e50b5'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=4a5478d6'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=681cfd29'; +import { initList, render, syncFilterUI } from '/js/admin-list.js?v=8524801f'; import { resolveAdminSection } from '/js/admin-logic.js?v=dcf7c37d'; import { buildAppItem, @@ -9,11 +9,11 @@ import { saveWithRevert, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=48a9e055'; -import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=f019211c'; -import { ag, ap, initInlineEdit, paintIcon, setReauthHandler, toast } from '/js/admin-shared.js?v=132c869f'; +} from '/js/admin-save-logic.js?v=4f71ef6c'; +import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=909c58ad'; +import { ag, ap, initInlineEdit, paintIcon, setReauthHandler, toast } from '/js/admin-shared.js?v=d7dd8b71'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=0ca20e21'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=cc1dfdec'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=e644a5c5'; import { loadLocalIcons } from '/js/icons.js?v=69c2b9bd'; @@ -25,13 +25,13 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=ef4f3d44'; +} from '/js/import-foreign.js?v=722d1c25'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=9de1cb7d'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff'; import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=00c011c9'; -import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=d949e985'; -import { normalizeColorInput } from '/js/admin-color-control.js?v=3a61c02f'; -import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=1907cce7'; +import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=4c1189b9'; +import { normalizeColorInput } from '/js/admin-color-control.js?v=f638b750'; +import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; /* A class rather than a bare media query. Some phones report a wider CSS @@ -238,7 +238,7 @@ function _renderEditBody() { function openModal(idx) { const editing = idx != null ? state.items[idx] : null; state.eid = editing?.id ?? null; - const item = editing ? JSON.parse(JSON.stringify(editing)) : null; + const item = editing ? structuredClone(editing) : null; state.ctype = item?.type || 'app'; state.siurl = item?.iconUrl || ''; state.scol = item?.color || 'dark'; diff --git a/public/js/brand-mark.js b/public/js/brand-mark.js new file mode 100644 index 0000000..1e4c4ff --- /dev/null +++ b/public/js/brand-mark.js @@ -0,0 +1,6 @@ +// @ts-check +/* Do not move this to /icons. That path is a user volume. A file of the same + name left there shadows the bundled artwork. */ + +export const BRAND_MARK = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPgoKPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQuNiw3My40KSBzY2FsZSgwLjg0NikiPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsMTAyNCkgc2NhbGUoMC4xLC0wLjEpIj48cGF0aCBmaWxsPSIjMDBEMkUwIiBkPSJNNDk3OCA5MTgwIGMtNzUgLTEzIC0xNDYgLTM5IC0yMzMgLTgzIC0xMTIgLTU3IC0yNDQgLTE5MCAtMzAyIC0zMDIgLTcwIC0xMzUgLTg4IC0yMTAgLTg4IC0zNjAgMCAtMTUwIDE4IC0yMjUgODggLTM2MCA1OCAtMTEyIDE5MCAtMjQ0IDMwMiAtMzAyIDE0MSAtNzIgMTkzIC04NCA0MzAgLTkzIDM1NCAtMTUgNjAyIC02NCA4ODUgLTE3NyA4MDMgLTMxOSAxNDAwIC0xMDI4IDE1NjkgLTE4NjMgMzkgLTE5NSA0NiAtMjcyIDQ2IC01MjAgMCAtMTU1IC01IC0yNzggLTE0IC0zMzUgLTUwIC0zMzIgLTEyOSAtNTc4IC0yODEgLTg3MCAtMTA4IC0yMDcgLTEyNSAtMjY2IC0xMjUgLTQzMCAwIC0xNTAgMTggLTIyNSA4OCAtMzYwIDU4IC0xMTIgMTkwIC0yNDQgMzAyIC0zMDIgMTM1IC03MCAyMTAgLTg4IDM2MCAtODggMTA4IDAgMTQzIDQgMjA1IDI0IDE3NCA1NCAzMzAgMTY3IDQyNCAzMDkgNjUgMTAwIDE5NSAzNjYgMjY5IDU1MiAxMzUgMzQzIDIxOSA2NzcgMjYzIDEwNTUgMTMgMTExIDE4IDIyNiAxOCA0NDUgMCAzMTggLTEzIDQ3MyAtNjAgNzMwIC0xMDIgNTUwIC0zMDkgMTA2NCAtNjE3IDE1MjUgLTY2OCAxMDAyIC0xNzQyIDE2NTYgLTI5MzcgMTc5MCAtMTY5IDE5IC01MjQgMjggLTU5MiAxNXoiLz48cGF0aCBmaWxsPSIjRjJGMkY3IiBkPSJNMjEzMSA3NTQ5IGMtMjg3IC01MSAtNTI5IC0yNjQgLTYyMyAtNTQ5IC0yOSAtODcgLTMyIC0xMDYgLTMyIC0yMzAgMCAtMTExIDMgLTE0OSAyMiAtMjEzIDU1IC0xOTEgMTczIC0zNTQgMzMwIC00NTQgMzIgLTIxIDQyNiAtMjQ5IDg3NSAtNTA4IDcwNyAtNDA3IDgxNCAtNDcyIDc5OSAtNDgzIC05IC03IC0zOTYgLTIzMiAtODYxIC01MDAgLTc0NyAtNDMxIC04NTIgLTQ5NSAtOTE3IC01NTUgLTMxNCAtMjkzIC0zMzggLTc2MyAtNTYgLTExMDQgODUgLTEwMyAyNTcgLTIxMCA0MDIgLTI0OSA0NyAtMTMgMTAyIC0xOCAxOTUgLTE4IDE0NiAwIDIxNCAxNCAzMzAgNjkgMzkgMTggNDQxIDI0NyA4OTQgNTA5IDQ1MyAyNjIgODI2IDQ3NiA4MjggNDc2IDEgMCAzIC00NDUgMyAtOTg5IDAgLTEwNzkgLTEgLTEwNjMgNTggLTEyMTMgNzcgLTE5OSAyNDkgLTM2OSA0NDggLTQ0NCA0NzAgLTE3NyA5NzYgMTA0IDEwNzAgNTkzIDEyIDYzIDE0IDMzOSAxNCAxNzIzIDAgMTA2NCAtNCAxNjg1IC0xMCAxNzUxIC0yNSAyNDcgLTExOSA0MzUgLTI4NCA1NzEgLTYxIDUxIC0yODQ0IDE2NjYgLTMwMTQgMTc0OSAtNDAgMjEgLTEwNCA0NSAtMTQwIDU0IC03OSAyMCAtMjU1IDI4IC0zMzEgMTR6Ii8+PC9nPjwvZz4KPC9zdmc+Cg=='; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 27542dc..c90f5a8 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -11,10 +11,12 @@ import { } from '/js/widget-types.js?v=a1b61636'; import { el, + isDashboardEmpty, mk, mkWrap as _mkWrap, mountScaledWidget, pageDir, + renderEmptyState, q, qa, qi, @@ -22,13 +24,13 @@ import { setUserText, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=d949e985'; -import { initSpotlight } from '/js/spotlight.js?v=687fae26'; +} from '/js/utils.js?v=4c1189b9'; +import { initSpotlight } from '/js/spotlight.js?v=c8d5d2d8'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=e644a5c5'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; -import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=82e0bf6b'; +import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=b71e1c75'; 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'; @@ -81,10 +83,14 @@ function gridMetrics() { } let gm = { tile: DESIGN_TILE, rowGap: DESIGN_ROW_GAP, scale: 1 }; -function desktopSlots() { +/* The reserves mirror the .page padding in dashboard.css. Change one and the + page breaks stop matching the box. + + @param {boolean} hasDock */ +function desktopSlots(hasDock) { const ih = innerHeight; const top = Math.min(70, Math.max(44, ih * 0.04)); - const bottom = Math.min(160, Math.max(110, ih * 0.1)); + const bottom = hasDock ? Math.min(160, Math.max(110, ih * 0.1)) : top; const rows = Math.max(1, Math.min(4, Math.floor((ih - top - bottom + gm.rowGap) / (gm.tile + gm.rowGap)))); return DCOLS * rows; } @@ -265,11 +271,13 @@ function paginate() { .flatMap(f => f.children || []) .map(String), ); - const budget = desktopSlots(); + const budget = desktopSlots(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; @@ -379,6 +387,7 @@ function buildDesktop() { /* Before paginate() and before any tile is built: both size against it. */ gm = gridMetrics(); const dock = items.filter(i => i.type === 'app' && i.dock && !i.hidden).slice(0, 4); + document.body.classList.toggle('no-dock', !dock.length); const pages = paginate(); totalPages = pages.length; const strip = el('pages'); @@ -392,6 +401,7 @@ function buildDesktop() { p.appendChild(g); strip.appendChild(p); }); + renderEmptyState(items); const dots = el('dots'); dots.style.cssText = ''; dots.replaceChildren(); @@ -400,6 +410,7 @@ function buildDesktop() { const dk = el('dock'); dk.replaceChildren(); dock.forEach(item => dk.appendChild(mkDock(item))); + dk.hidden = !dock.length; } /* Every page is mounted at once, so widgets the user has swiped away from keep diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js index 799377e..f1e5e99 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=48a9e055'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=4f71ef6c'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ diff --git a/public/js/mobile-metrics.js b/public/js/mobile-metrics.js index 70db323..43eb283 100644 --- a/public/js/mobile-metrics.js +++ b/public/js/mobile-metrics.js @@ -36,10 +36,11 @@ export function gridCellCount({ gridW, gridH, sc }) { return { cols, rows }; } -export function mobileMetrics(vw) { +/** @param {number} vw @param {boolean} docked */ +export function mobileMetrics(vw, docked = true) { const sc = Math.min(vw / BASE_VW, MAX_SC); const sm = Math.round(18 * sc); - const dh = Math.round(108 * sc); + const dh = docked ? Math.round(108 * sc) : 0; const pillH = Math.round(34 * sc); const pillGap = Math.round(10 * sc); /* Reserving less lets the pill sit on top of the last row. */ diff --git a/public/js/spotlight.js b/public/js/spotlight.js index 4e7dc18..b0aff04 100644 --- a/public/js/spotlight.js +++ b/public/js/spotlight.js @@ -1,4 +1,4 @@ -import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=d949e985'; +import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=4c1189b9'; import { t } from '/js/i18n.js?v=e644a5c5'; /* Attached to the window so a re-open can undo the previous one. */ diff --git a/public/js/ui.js b/public/js/ui.js index ea5cf67..972351a 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -3,6 +3,8 @@ import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-ty import { mk, clr, + isDashboardEmpty, + renderEmptyState, mkWrap as _mkWrap, mountScaledWidget, pageDir, @@ -11,10 +13,10 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=d949e985'; +} from '/js/utils.js?v=4c1189b9'; import { t, currentLang } from '/js/i18n.js?v=e644a5c5'; import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; -import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=7be08bb0'; +import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=ab5fe77e'; let _state = null; export function initUI(state) { @@ -547,7 +549,11 @@ export function buildMobile() { st().BEL.clear(); const vw = innerWidth, vh = innerHeight; - const { sc, sm, dh, pillH, pillGap, dz } = mobileMetrics(vw); + const dock = items() + .filter(i => i.type === 'app' && i.dock && !i.hidden) + .slice(0, 4); + const { sc, sm, dh, pillH, pillGap, dz } = mobileMetrics(vw, dock.length > 0); + document.body.classList.toggle('no-dock', !dock.length); const gap = Math.round(sm * 0.5); css(document.body, { '--sc': String(sc), @@ -610,9 +616,6 @@ export function buildMobile() { const isz = Math.round(Math.min(cw * 0.9, rh * 0.8, maxIsz)); const ir = Math.round(isz * 0.225), im = Math.round(isz * 0.64); - const dock = items() - .filter(i => i.type === 'app' && i.dock && !i.hidden) - .slice(0, 4); const showLabel = S().showLabels?.ios === true; /* The reference draws a 66 icon and a 28 widget corner, so a widget's corner is 0.424 of the icon it sits beside. */ @@ -626,7 +629,8 @@ export function buildMobile() { .flatMap(f => f.children || []) .map(String), ); - const gridItems = items().filter(i => !i.dock && !i.hidden && !inFolder.has(String(i.id))); + const bare = isDashboardEmpty(items()); + const gridItems = items().filter(i => !i.dock && !i.hidden && !inFolder.has(String(i.id)) && !(bare && i.system)); function packMobile(list) { const pages = []; @@ -743,6 +747,7 @@ export function buildMobile() { /* Nothing was placed, so the measuring page would show as a blank first page. */ if (!pages.length) firstPage.page.remove(); + renderEmptyState(items()); const dw = el('dots'); dw.style.cssText = 'display:none'; @@ -763,6 +768,7 @@ export function buildMobile() { ? dock.length * dockIconSz + (dock.length - 1) * Math.round(22 * sc) + dockPad * 2 : maxDockW; const dockW = Math.min(maxDockW, dockContentW); + 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.replaceChildren(); dock.forEach(item => { @@ -782,7 +788,7 @@ export function buildMobile() { _pdotPad = Math.round(14 * sc); const pillDotsW = pages.length * (_pdotSz + _pdotGap) - _pdotGap + _pdotPad * 2; const pill = el('mob-search-pill'); - pill.style.cssText = `position:fixed;left:50%;bottom:${dockGap + dh + pillGap}px;transform:translateX(-50%);width:${pillSearchW}px;height:${pillH}px;display:flex;z-index:500;`; + pill.style.cssText = `position:fixed;left:50%;bottom:${dockGap + dh + pillGap}px;transform:translateX(-50%);width:${pillSearchW}px;height:${pillH}px;display:${bare ? 'none' : 'flex'};z-index:500;`; const pillNew = /** @type {HTMLElement} */ (pill.cloneNode(true)); const pillNewDots = q('.msp-dots', pillNew); diff --git a/public/js/utils.js b/public/js/utils.js index 0966259..affedf6 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,6 +1,8 @@ import { iconChain } from '/js/icons.js?v=69c2b9bd'; import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; import { SETTINGS_ICON } from '/js/settings-icon.js?v=b96e5b13'; +import { BRAND_MARK } from '/js/brand-mark.js?v=1dcbf1ac'; +import { t } from '/js/i18n.js?v=e644a5c5'; export const mk = (t, a = {}) => { const e = document.createElement(t); @@ -400,3 +402,33 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla _mounts.add(stop); return ifr; } + +/** @param {any[]} items */ +export const isDashboardEmpty = items => !(items || []).some(i => i && !i.system && !i.hidden); + +/* Keep this on the body. A child of the page strip counts as a page. + + @param {any[]} items */ +export function renderEmptyState(items) { + document.querySelector('.empty-state')?.remove(); + if (!isDashboardEmpty(items)) return; + + const box = mk('div'); + box.className = 'empty-state'; + const mark = mk('img', { src: BRAND_MARK, alt: '' }); + mark.className = 'empty-state-mark'; + const title = mk('p'); + title.className = 'empty-state-title'; + title.textContent = t('home.emptyTitle'); + const sub = mk('p'); + sub.className = 'empty-state-sub'; + sub.textContent = t('home.emptyBody'); + const btn = mk('a', { href: '/admin/' }); + btn.className = 'empty-state-btn'; + btn.textContent = t('home.emptyAction'); + const hint = mk('a', { href: '/admin/' }); + hint.className = 'empty-state-hint'; + hint.textContent = t('home.emptyImportLink'); + box.append(mark, title, sub, btn, hint); + document.body.appendChild(box); +} diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index d4841af..9210ea2 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -4,8 +4,8 @@ import { t } from '/js/i18n.js?v=e644a5c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { wireChecklist } from '/js/admin-shared.js?v=132c869f'; -import { renderColorControl } from '/js/admin-color-control.js?v=3a61c02f'; +import { wireChecklist } from '/js/admin-shared.js?v=d7dd8b71'; +import { renderColorControl } from '/js/admin-color-control.js?v=f638b750'; import { seedCarried, applyOptionSet, @@ -15,7 +15,7 @@ import { visibleFieldFlags, } from '/js/admin-logic.js?v=dcf7c37d'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; -import { qi } from '/js/utils.js?v=d949e985'; +import { qi } from '/js/utils.js?v=4c1189b9'; const PE = ''; diff --git a/public/js/yaml-lite.js b/public/js/yaml-lite.js index b610be8..445daad 100644 --- a/public/js/yaml-lite.js +++ b/public/js/yaml-lite.js @@ -211,7 +211,7 @@ function scan(text) { if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); const rows = text.split(/\r\n|\r|\n/); /* The empty string after a final newline is not a line of the file. */ - if (rows.length > 1 && rows[rows.length - 1] === '') rows.pop(); + if (rows.length > 1 && rows.at(-1) === '') rows.pop(); for (let i = 0; i < rows.length; i++) { const raw = rows[i]; diff --git a/src/content/docs/docs/adding-services.md b/src/content/docs/docs/adding-services.md index ac7c72e..2eadc4d 100644 --- a/src/content/docs/docs/adding-services.md +++ b/src/content/docs/docs/adding-services.md @@ -32,6 +32,8 @@ The dock is the row of icons pinned at the bottom of the dashboard. Turn on Show It holds four apps. Once full, the toggle refuses more until you remove one. Widgets cannot go in the dock. +The dock is hidden while no app is in it, and the grid uses the space instead. + ## Folders A folder groups apps behind one tile, whose artwork is a grid of the icons inside it. diff --git a/src/content/docs/docs/first-setup.md b/src/content/docs/docs/first-setup.md index 41539c7..825c2dd 100644 --- a/src/content/docs/docs/first-setup.md +++ b/src/content/docs/docs/first-setup.md @@ -5,6 +5,8 @@ description: Open the admin UI, set a password, and learn what the four sections Open `http://localhost:8700/admin`. The Settings icon on the dashboard goes to the same place. +A dashboard with nothing on it shows a welcome screen instead of tiles. Its **Open settings** button goes to the admin UI, and the link under it goes to the same place to import a [backup](/docs/import-export/backup-and-restore/) or [another dashboard's config](/docs/import-export/migrating/). The Settings icon returns once the dashboard holds something. + ## Set a password first In **General**, turn on Password Protection. The minimum length is 8 characters.