From 5f4606b365a0b48af71ab008764712b67aace3b4 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:22:40 -0400 Subject: [PATCH 1/2] Group the dashboard list into Dock and Home screen Docked apps are listed in their own Dock group above the Home screen group. Rows can be dragged into, out of and within the dock; a full dock refuses a fifth app, and widgets and folders never join it. The Settings tile gets an editor with Show Settings on dashboard and Show in Dock. --- e2e/dock-list.spec.js | 208 ++++++++++++++++++++++++++++++ e2e/rtl-mirroring.spec.js | 2 +- ui/admin/index.html | 6 +- ui/css/admin.css | 4 + ui/i18n/de.json | 7 +- ui/i18n/en.json | 7 +- ui/i18n/es.json | 7 +- ui/i18n/fa.json | 7 +- ui/i18n/fr.json | 7 +- ui/i18n/zh-Hans.json | 7 +- ui/js/admin-app-form.js | 24 +++- ui/js/admin-auth.js | 2 +- ui/js/admin-color-control.js | 4 +- ui/js/admin-drag-logic.js | 23 +++- ui/js/admin-drag.js | 102 +++++++++++++-- ui/js/admin-list.js | 109 ++++++++++------ ui/js/admin-logic.js | 17 ++- ui/js/admin-settings.js | 6 +- ui/js/admin-shared.js | 2 +- ui/js/admin-widget-form.js | 8 +- ui/js/admin.js | 37 ++++-- ui/js/listbox.js | 2 +- ui/js/widget-config-form.js | 8 +- ui/test/admin-drag-logic.test.mjs | 103 ++++++++++++++- ui/test/admin-logic.test.mjs | 41 ++++++ 25 files changed, 640 insertions(+), 110 deletions(-) create mode 100644 e2e/dock-list.spec.js diff --git a/e2e/dock-list.spec.js b/e2e/dock-list.spec.js new file mode 100644 index 00000000..b5c102f3 --- /dev/null +++ b/e2e/dock-list.spec.js @@ -0,0 +1,208 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { seedConfig, readConfig, saveEditor, app, openDashboardList, rowByName } = require('./helpers'); + +const docked = (id, label) => ({ ...app(id, label), dock: true }); + +/** Names in one list group, in order. @param {'dock'|'home'} group */ +function groupNames(page, group) { + return page.locator(`#al .grp[data-group="${group}"] > .drow .rnm`).allTextContents(); +} + +function waitForSave(page) { + return page.waitForResponse(r => r.url().includes('/api/config') && r.request().method() === 'POST'); +} + +async function dragRow(from, to, where = 'above') { + const box = await to.boundingBox(); + if (!box) throw new Error('drop target has no box'); + await from.dragTo(to, { targetPosition: { x: box.width / 2, y: where === 'above' ? 4 : box.height - 4 } }); +} + +/** Ids of the docked items the server holds, in order. */ +/** Flip a switch the way a person does. The checkbox itself is drawn hidden. */ +function flip(page, id) { + return page.locator(`label.tog:has(#${id})`).click(); +} + +async function savedDock(request) { + return (await readConfig(request)).items.filter(i => i.dock).map(i => i.id); +} + +test('docked apps are listed in the Dock group, everything else under Home screen', async ({ page, request }) => { + await seedConfig(request, { + items: [app('alpha', 'Alpha'), docked('dee', 'Dee'), app('bravo', 'Bravo'), docked('eff', 'Eff')], + }); + await openDashboardList(page); + expect(await groupNames(page, 'dock')).toEqual(['Dee', 'Eff']); + expect(await groupNames(page, 'home')).toEqual(['Alpha', 'Bravo', 'Settings']); + await expect(page.locator('#al .al-count')).toHaveText('2/4'); + await expect(page.locator('#al .p-dk')).toHaveCount(0); +}); + +test('dragging an app onto a dock row docks it at that place', async ({ page, request }) => { + await seedConfig(request, { items: [docked('dee', 'Dee'), docked('eff', 'Eff'), app('alpha', 'Alpha')] }); + await openDashboardList(page); + const saved = waitForSave(page); + await dragRow(rowByName(page, 'Alpha'), rowByName(page, 'Eff'), 'above'); + expect((await saved).status()).toBe(200); + expect(await groupNames(page, 'dock')).toEqual(['Dee', 'Alpha', 'Eff']); + expect(await savedDock(request)).toEqual(['dee', 'alpha', 'eff']); +}); + +test('dragging a docked app onto a home row undocks it', async ({ page, request }) => { + await seedConfig(request, { items: [docked('dee', 'Dee'), app('alpha', 'Alpha'), app('bravo', 'Bravo')] }); + await openDashboardList(page); + const saved = waitForSave(page); + await dragRow(rowByName(page, 'Dee'), rowByName(page, 'Bravo'), 'above'); + expect((await saved).status()).toBe(200); + expect(await groupNames(page, 'home')).toEqual(['Alpha', 'Dee', 'Bravo', 'Settings']); + expect(await savedDock(request)).toEqual([]); + await expect(page.locator('#al .al-dock-empty')).toBeVisible(); +}); + +test('dragging inside the dock reorders it', async ({ page, request }) => { + await seedConfig(request, { items: [docked('dee', 'Dee'), docked('eff', 'Eff'), docked('gee', 'Gee')] }); + await openDashboardList(page); + const saved = waitForSave(page); + await dragRow(rowByName(page, 'Gee'), rowByName(page, 'Dee'), 'above'); + await saved; + expect(await savedDock(request)).toEqual(['gee', 'dee', 'eff']); +}); + +test('a full dock refuses a fifth app and says why', async ({ page, request }) => { + const four = ['d1', 'd2', 'd3', 'd4'].map(id => docked(id, id.toUpperCase())); + await seedConfig(request, { items: [...four, app('alpha', 'Alpha')] }); + await openDashboardList(page); + const before = await readConfig(request); + let posted = false; + page.on('request', r => { + if (r.url().includes('/api/config') && r.method() === 'POST') posted = true; + }); + await dragRow(rowByName(page, 'Alpha'), rowByName(page, 'D2'), 'above'); + await expect(page.locator('#toast')).toHaveText('Dock full (4/4). Remove an app first.'); + expect(posted).toBe(false); + expect((await readConfig(request)).items).toEqual(before.items); +}); + +test('the empty dock is a drop target', async ({ page, request }) => { + await seedConfig(request, { items: [app('alpha', 'Alpha'), app('bravo', 'Bravo')] }); + await openDashboardList(page); + const zone = page.locator('#al .al-dock-empty'); + await expect(zone).toHaveText('Drag an app here, or open it and turn on Show in Dock.'); + const saved = waitForSave(page); + await rowByName(page, 'Bravo').dragTo(zone); + await saved; + expect(await groupNames(page, 'dock')).toEqual(['Bravo']); + expect(await savedDock(request)).toEqual(['bravo']); + await expect(zone).toHaveCount(0); +}); + +test('a widget or folder never joins the dock', async ({ page, request }) => { + await seedConfig(request, { + items: [ + docked('dee', 'Dee'), + { id: 'clk', type: 'widget', label: 'Clock', widgetType: 'clock', widgetSize: 'small' }, + { id: 'box', type: 'folder', label: 'Box', children: [] }, + ], + }); + await openDashboardList(page); + const before = await readConfig(request); + for (const name of ['Clock', 'Box']) { + await dragRow(rowByName(page, name), rowByName(page, 'Dee'), 'above'); + } + await expect(page.locator('#toast')).not.toHaveText(/Dock full/); + expect(await groupNames(page, 'dock')).toEqual(['Dee']); + expect((await readConfig(request)).items).toEqual(before.items); +}); + +test('an app dragged out of a folder into the dock leaves the folder', async ({ page, request }) => { + await seedConfig(request, { + items: [ + docked('dee', 'Dee'), + { id: 'box', type: 'folder', label: 'Box', children: ['alpha'] }, + app('alpha', 'Alpha'), + ], + }); + await openDashboardList(page); + await rowByName(page, 'Box').locator('.rnm').click(); + await expect(rowByName(page, 'Alpha')).toHaveAttribute('data-indent', '1'); + const saved = waitForSave(page); + await dragRow(rowByName(page, 'Alpha'), rowByName(page, 'Dee'), 'below'); + await saved; + const cfg = await readConfig(request); + expect(cfg.items.find(i => i.id === 'box').children).toEqual([]); + expect(await savedDock(request)).toEqual(['dee', 'alpha']); +}); + +test('the move buttons stay inside a group', async ({ page, request }) => { + await seedConfig(request, { items: [docked('dee', 'Dee'), app('alpha', 'Alpha'), docked('eff', 'Eff')] }); + await openDashboardList(page); + await expect(rowByName(page, 'Eff').getByRole('button', { name: 'Move down: Eff' })).toBeDisabled(); + await expect(rowByName(page, 'Alpha').getByRole('button', { name: 'Move up: Alpha' })).toBeDisabled(); + const saved = waitForSave(page); + await rowByName(page, 'Eff').getByRole('button', { name: 'Move up: Eff' }).click(); + await saved; + expect(await savedDock(request)).toEqual(['eff', 'dee']); + expect(await groupNames(page, 'home')).toEqual(['Alpha', 'Settings']); +}); + +test('Settings goes into the dock from its editor', async ({ page, request }) => { + await seedConfig(request, { items: [app('alpha', 'Alpha')] }); + await openDashboardList(page); + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await expect(page.locator('#ev-delete')).toBeHidden(); + const dock = page.locator('#f-dock'); + const shown = page.locator('#f-shown'); + await expect(shown).toBeChecked(); + await expect(dock).not.toBeChecked(); + await flip(page, 'f-dock'); + await expect(dock).toBeChecked(); + await saveEditor(page); + expect(await groupNames(page, 'dock')).toEqual(['Settings']); + const s = (await readConfig(request)).items.find(i => i.system === 'settings'); + expect(s.dock).toBe(true); + expect(s.hidden).toBeUndefined(); +}); + +test('Settings can be hidden and shown again from its editor', async ({ page, request }) => { + await seedConfig(request, { items: [app('alpha', 'Alpha')] }); + await openDashboardList(page); + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await flip(page, 'f-shown'); + await saveEditor(page); + await expect(rowByName(page, 'Settings').locator('.p-hd')).toBeVisible(); + expect((await readConfig(request)).items.find(i => i.system === 'settings').hidden).toBe(true); + + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await expect(page.locator('#f-shown')).not.toBeChecked(); + await flip(page, 'f-shown'); + await saveEditor(page); + expect((await readConfig(request)).items.find(i => i.system === 'settings').hidden).toBeUndefined(); +}); + +test('Settings cannot join a full dock from its editor', async ({ page, request }) => { + const four = ['d1', 'd2', 'd3', 'd4'].map(id => docked(id, id.toUpperCase())); + await seedConfig(request, { items: four }); + await openDashboardList(page); + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await expect(page.locator('#dock-full-tip')).toBeVisible(); + await expect(page.locator('#f-dock')).toHaveAttribute('aria-disabled', 'true'); + await page.locator('label.tog:has(#f-dock)').click({ force: true }); + await expect(page.locator('#f-dock')).not.toBeChecked(); +}); + +test('docking an app from its editor takes it out of its folder', async ({ page, request }) => { + await seedConfig(request, { + items: [{ id: 'box', type: 'folder', label: 'Box', children: ['alpha'] }, app('alpha', 'Alpha')], + }); + await openDashboardList(page); + await rowByName(page, 'Box').locator('.rnm').click(); + await rowByName(page, 'Alpha').getByRole('button', { name: 'Edit' }).click(); + await flip(page, 'f-dock'); + await saveEditor(page); + const cfg = await readConfig(request); + expect(cfg.items.find(i => i.id === 'box').children).toEqual([]); + expect(await savedDock(request)).toEqual(['alpha']); + expect(await groupNames(page, 'dock')).toEqual(['Alpha']); +}); diff --git a/e2e/rtl-mirroring.spec.js b/e2e/rtl-mirroring.spec.js index 9c73d991..84e0a7f0 100644 --- a/e2e/rtl-mirroring.spec.js +++ b/e2e/rtl-mirroring.spec.js @@ -44,7 +44,7 @@ test('a row title and its subtitle share a leading edge', async ({ page, request await seedPersian(request, [app('latin', 'Backup and Storage')]); await page.goto('/admin/'); await page.locator('.nl[data-sec="dashboard"]').click(); - const row = page.locator('#al .row').first(); + const row = page.locator('#al .drow').first(); await row.waitFor({ state: 'visible' }); /* Both boxes span the row, so their edges always agree. What moves is where the text sits inside them, which is the rect of the content itself. */ diff --git a/ui/admin/index.html b/ui/admin/index.html index 9030d246..5d570497 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -11,7 +11,7 @@ - + @@ -204,7 +204,7 @@ -
+
@@ -302,6 +302,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index af29dc6a..b1fd9f0c 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -309,6 +309,10 @@ html{background:var(--bg-color,var(--bg-base));scrollbar-gutter:stable} .row.drow.drag-above{box-shadow:0 -2px 0 0 var(--ac)} .row.drow.drag-below{box-shadow:0 2px 0 0 var(--ac)} .row.drow.drag-into{background:var(--row-drop);box-shadow:inset 0 0 0 2px var(--gn)} +.al-hdr{align-items:baseline} +.al-count{padding:26px 16px 6px;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);color:var(--dm);font-variant-numeric:tabular-nums} +.row.al-dock-empty{min-height:68px;justify-content:center;text-align:center;padding-block:10px;color:var(--dm);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote)} +.row.al-dock-empty.drag-into{background:var(--row-drop);box-shadow:inset 0 0 0 2px var(--gn);color:var(--tx)} /* touch-action:none, or a drag starting on the handle scrolls the list instead. */ .rord{display:flex;align-items:center;justify-content:center;color:var(--dm);font-size:var(--fs-callout);line-height:var(--lh-callout);letter-spacing:var(--tr-callout);touch-action:none} .rico{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--on-tint);flex-shrink:0;overflow:hidden;box-shadow:inset 0 0 0 1px var(--icon-ring)} diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 864451ce..7a5706b0 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -31,8 +31,6 @@ "chooseFiles": "Dateien wählen", "configured": "Eingerichtet", "notSet": "Nicht festgelegt", - "show": "Anzeigen", - "hide": "Ausblenden", "moveUp": "Nach oben", "moveDown": "Nach unten", "editNamed": "{name} bearbeiten", @@ -70,7 +68,6 @@ "logError": "Fehler", "passwordPh": "Neues Passwort (mind. 8 Zeichen)", "showSettingsAria": "Einstellungen im Dashboard anzeigen", - "hideSettingsAria": "Einstellungen im Dashboard ausblenden", "importJson": "Konfigurations-JSON importieren", "importForeignFiles": "Homepage- oder Dashy-YAML-Konfiguration importieren" }, @@ -392,7 +389,9 @@ }, "list": { "empty": "Noch keine Apps. Klicke auf +Hinzufügen.", - "noMatches": "Keine Treffer." + "noMatches": "Keine Treffer.", + "dockTip": "Erscheint unten auf jeder Seite.", + "dockEmpty": "Ziehe eine App hierher oder öffne sie und aktiviere „Im Dock anzeigen“." }, "import": { "confirm_one": "{count} Eintrag importieren? {added} hinzugefügt, {updated} aktualisiert, {deleted} gelöscht.", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 55a82535..93e702b8 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -31,8 +31,6 @@ "chooseFiles": "Choose files", "configured": "Configured", "notSet": "Not set", - "show": "Show", - "hide": "Hide", "moveUp": "Move up", "moveDown": "Move down", "editNamed": "Edit {name}", @@ -70,7 +68,6 @@ "logError": "Errors", "passwordPh": "New password (min 8 chars)", "showSettingsAria": "Show Settings on dashboard", - "hideSettingsAria": "Hide Settings on dashboard", "importJson": "Import configuration JSON", "importForeignFiles": "Import Homepage or Dashy YAML config" }, @@ -392,7 +389,9 @@ }, "list": { "empty": "No apps yet. Click +Add.", - "noMatches": "No matches." + "noMatches": "No matches.", + "dockTip": "Shown at the bottom of every page.", + "dockEmpty": "Drag an app here, or open it and turn on Show in Dock." }, "import": { "confirm_one": "Import {count} item? {added} added, {updated} updated, {deleted} deleted.", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index b752d08d..368d4bdb 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -31,8 +31,6 @@ "chooseFiles": "Elegir archivos", "configured": "Configurado", "notSet": "Sin definir", - "show": "Mostrar", - "hide": "Ocultar", "moveUp": "Subir", "moveDown": "Bajar", "editNamed": "Editar {name}", @@ -70,7 +68,6 @@ "logError": "Errores", "passwordPh": "Nueva contraseña (mín. 8 caracteres)", "showSettingsAria": "Mostrar Ajustes en el panel", - "hideSettingsAria": "Ocultar Ajustes en el panel", "importJson": "Importar JSON de configuración", "importForeignFiles": "Importar configuración YAML de Homepage o Dashy" }, @@ -398,7 +395,9 @@ }, "list": { "empty": "Aún no hay aplicaciones. Haz clic en +Añadir.", - "noMatches": "Sin coincidencias." + "noMatches": "Sin coincidencias.", + "dockTip": "Aparece en la parte inferior de cada página.", + "dockEmpty": "Arrastra una aplicación aquí, o ábrela y activa «Mostrar en el Dock»." }, "import": { "confirm_one": "¿Importar {count} elemento? Añadidos: {added}, actualizados: {updated}, eliminados: {deleted}.", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 5447effa..60e1ab9a 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -31,8 +31,6 @@ "chooseFiles": "انتخاب فایل‌ها", "configured": "تنظیم‌شده", "notSet": "تنظیم نشده", - "show": "نمایش", - "hide": "پنهان کردن", "moveUp": "انتقال به بالا", "moveDown": "انتقال به پایین", "editNamed": "ویرایش {name}", @@ -70,7 +68,6 @@ "logError": "خطاها", "passwordPh": "رمز عبور جدید (حداقل ۸ کاراکتر)", "showSettingsAria": "نمایش تنظیمات در داشبورد", - "hideSettingsAria": "پنهان کردن تنظیمات در داشبورد", "importJson": "وارد کردن JSON پیکربندی", "importForeignFiles": "وارد کردن پیکربندی YAML مربوط به Homepage یا Dashy" }, @@ -392,7 +389,9 @@ }, "list": { "empty": "هنوز برنامه‌ای وجود ندارد. روی «+ افزودن» کلیک کنید.", - "noMatches": "نتیجه‌ای یافت نشد." + "noMatches": "نتیجه‌ای یافت نشد.", + "dockTip": "در پایین همه صفحه‌ها نمایش داده می‌شود.", + "dockEmpty": "یک برنامه را به اینجا بکشید، یا آن را باز کنید و «نمایش در داک» را روشن کنید." }, "import": { "confirm_one": "{count} مورد وارد شود؟ {added} مورد اضافه، {updated} مورد به‌روزرسانی و {deleted} مورد حذف می‌شود.", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 552dedb0..0259eda5 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -31,8 +31,6 @@ "chooseFiles": "Choisir des fichiers", "configured": "Défini", "notSet": "Non défini", - "show": "Afficher", - "hide": "Masquer", "moveUp": "Monter", "moveDown": "Descendre", "editNamed": "Modifier {name}", @@ -70,7 +68,6 @@ "logError": "Erreurs", "passwordPh": "Nouveau mot de passe (8 caractères minimum)", "showSettingsAria": "Afficher les Réglages sur le tableau de bord", - "hideSettingsAria": "Masquer les Réglages sur le tableau de bord", "importJson": "Importer le JSON de configuration", "importForeignFiles": "Importer une configuration YAML Homepage ou Dashy" }, @@ -398,7 +395,9 @@ }, "list": { "empty": "Aucune application pour le moment. Cliquez sur +Ajouter.", - "noMatches": "Aucune correspondance." + "noMatches": "Aucune correspondance.", + "dockTip": "Affiché en bas de chaque page.", + "dockEmpty": "Faites glisser une application ici, ou ouvrez-la et activez « Afficher dans le Dock »." }, "import": { "confirm_one": "Importer {count} élément ? Ajoutés : {added}, mis à jour : {updated}, supprimés : {deleted}.", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index d6c8ce98..9c9a5850 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -31,8 +31,6 @@ "chooseFiles": "选择文件", "configured": "已设置", "notSet": "未设置", - "show": "显示", - "hide": "隐藏", "moveUp": "上移", "moveDown": "下移", "editNamed": "编辑{name}", @@ -70,7 +68,6 @@ "logError": "错误", "passwordPh": "新密码(至少 8 个字符)", "showSettingsAria": "在仪表板上显示设置", - "hideSettingsAria": "在仪表板上隐藏设置", "importJson": "导入配置 JSON", "importForeignFiles": "导入 Homepage 或 Dashy 的 YAML 配置" }, @@ -386,7 +383,9 @@ }, "list": { "empty": "还没有应用。点击“+ 添加”。", - "noMatches": "没有匹配项。" + "noMatches": "没有匹配项。", + "dockTip": "显示在每页底部。", + "dockEmpty": "将应用拖到这里,或打开应用并开启“在程序坞中显示”。" }, "import": { "confirm_other": "导入 {count} 个项目?新增 {added} 个,更新 {updated} 个,删除 {deleted} 个。", diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index b1f0bcca..af25e9ad 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -18,7 +18,7 @@ import { failureIsMissingApiPath, nextActiveIndex, sameIconName, -} from '/js/admin-logic.js?v=1cca36f5'; +} from '/js/admin-logic.js?v=67e24cd3'; import { t } from '/js/i18n.js?v=ed7c5565'; import { toast, @@ -31,10 +31,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=1c01be9a'; -import { createListbox } from '/js/listbox.js?v=f814170f'; +} from '/js/admin-shared.js?v=d27d7ec7'; +import { createListbox } from '/js/listbox.js?v=bcf3b016'; import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=4083ea3b'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=b2285dc7'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; @@ -90,6 +90,22 @@ function _wireFolderApps(apps, children) { row.appendChild(box.el); } +export function buildSettingsTileForm(body, item) { + const dockBlocked = isDockBlocked(state.items, item, DOCK_MAX); + const tog = (id, on, name) => + html``; + setHtml( + body, + html` +
+
${t('general.showSettingsAria')}${tog('f-shown', !item?.hidden, t('general.showSettingsAria'))}
+
${t('app.showInDock')}${tog('f-dock', !!item?.dock, t('app.showInDock'))}
+
+ ${dockBlocked ? html`

${t('app.dockFull', { max: formatNumber(DOCK_MAX) })}

` : ''}`, + ); + setTogDisabled(el('f-dock'), dockBlocked, 'dock-full-tip'); +} + export function buildAppForm(body, item) { const dockBlocked = isDockBlocked(state.items, item, DOCK_MAX); const globalHealthOn = !!inpById('srv-docker-en')?.checked; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index cc91ba9e..eb80ad5d 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=1c01be9a'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=d27d7ec7'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index cd548108..3e5b6d45 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,9 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=1c01be9a'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=d27d7ec7'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; -import { createListbox } from '/js/listbox.js?v=f814170f'; +import { createListbox } from '/js/listbox.js?v=bcf3b016'; 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']; diff --git a/ui/js/admin-drag-logic.js b/ui/js/admin-drag-logic.js index 28cdb36d..03c3a1bd 100644 --- a/ui/js/admin-drag-logic.js +++ b/ui/js/admin-drag-logic.js @@ -20,6 +20,23 @@ export function folderRowZone(y, rect) { return 'into'; } +/** The list group a top-level row is drawn in. + @param {any} item @returns {'dock' | 'home'} */ +export function listGroup(item) { + return item?.type === 'app' && item.dock ? 'dock' : 'home'; +} + +/** Whether the dragged item may land in a group. Only apps join the dock, and + only while it has room. + @param {any[]} items @param {any} src @param {'dock' | 'home'} group @param {number} max + @returns {boolean} */ +export function canDropInGroup(items, src, group, max) { + if (group !== 'dock') return true; + if (src?.type !== 'app') return false; + if (src.dock) return true; + return items.filter(i => listGroup(i) === 'dock').length < max; +} + export function dropTargetKind({ srcType, targetIsFolder = false, indent = false }) { if (canJoinFolder(srcType) && (targetIsFolder || indent)) return 'into-folder'; return 'reorder'; @@ -31,10 +48,14 @@ export function dropTargetKind({ srcType, targetIsFolder = false, indent = false targetIsFolder, indent the target is a folder, or sits inside one childIdx the target's index within its folder dropAbove insert before rather than after (reorder only) - The child object stays in `items` throughout. Folders reference it by id. */ + targetGroup, dockMax the group dropped in, and the dock's size + The child object stays in `items` throughout. Folders reference it by id. + With no targetId the item goes to the end of the group. */ export function applyDrop(items, d) { const src = items.find(i => i.id === d.srcId); if (!src || d.srcId === d.targetId) return false; + if (d.targetGroup && !canDropInGroup(items, src, d.targetGroup, d.dockMax ?? Infinity)) return false; + if (d.targetGroup && src.type === 'app') src.dock = d.targetGroup === 'dock'; if (d.srcFolderId) { const sf = items.find(i => i.id === d.srcFolderId); diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index cd139f43..acba18ad 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -9,28 +9,53 @@ directly. This module is the DOM half: what the pointer is doing, which edge it is near, and which classes say so. */ -import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; +import { applyDrop, canDropInGroup, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=1e25ae0a'; +import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; import { qa } from '/js/utils.js?v=705ca896'; /** @type {(before: unknown) => void} */ let _save = () => {}; +/** @type {() => void} */ +let _dockFull = () => {}; -/** The list writes the config, not this module. Call once at start-up. - @param {(before: unknown) => void} save @returns {void} */ -export function initDrag(save) { +/** The list writes the config and shows notices, not this module. Call once + at start-up. + @param {(before: unknown) => void} save @param {() => void} dockFull + @returns {void} */ +export function initDrag(save, dockFull) { _save = save; + _dockFull = dockFull; + /* Capture runs before the row handlers, which set it again over a refusing + row. So it describes the element under the pointer now. */ + document.addEventListener('dragover', () => (_refused = false), true); + document.addEventListener('drop', () => (_dropped = true), true); } /* Applies the arrangement and saves it, or leaves the list untouched. */ function commit(opts) { const before = snapshotItems(state.items); - if (applyDrop(state.items, opts)) _save(before); + if (applyDrop(state.items, { ...opts, dockMax: DOCK_MAX })) _save(before); } /* dataTransfer is not readable during dragover. */ let _dragType = null; +let _dragId = null; +/* The last target under the pointer refused the dragged row. */ +let _refused = false; +let _dropped = false; + +/** @param {string|null} srcId @param {string|undefined} group @returns {boolean} */ +function accepts(srcId, group) { + const src = state.items.find(i => i.id === srcId); + return canDropInGroup(state.items, src, group === 'dock' ? 'dock' : 'home', DOCK_MAX); +} + +/* Only an app is refused for a full dock. Other types never join it. */ +function isApp(srcId) { + return state.items.find(i => i.id === srcId)?.type === 'app'; +} function clearDragClasses(target) { const rows = target ? [target] : qa('.row'); @@ -110,13 +135,25 @@ function wireTouchDrag(row, handle, { indent, folderId }) { const place = (x, y) => { ghost.style.top = y - offY + 'px'; const under = /** @type {HTMLElement} */ (document.elementFromPoint(x, y)); - const tr = /** @type {HTMLElement} */ (under && under.closest('.drow')); + const tr = /** @type {HTMLElement} */ (under && under.closest('.drow, .al-dock-empty')); if (!tr || tr === row || tr === ghost) { unmark(); hovered = null; + _refused = false; return; } + if (!accepts(srcId, tr.dataset.group)) { + unmark(); + hovered = null; + _refused = isApp(srcId); + return; + } + _refused = false; hovered = tr; + if (tr.classList.contains('al-dock-empty')) { + mark(tr, 'drag-into'); + return; + } const r = tr.getBoundingClientRect(); if (tr.dataset.isFolder && canJoinFolder(itemType(srcId))) { const zone = folderRowZone(y, r); @@ -165,13 +202,22 @@ function wireTouchDrag(row, handle, { indent, folderId }) { if (scrollTimer) cancelAnimationFrame(scrollTimer); ghost.remove(); row.classList.remove('dragging'); + _refused = false; _marked = null; clearDragClasses(); }; const up = () => { const tr = hovered; + const refused = _refused; end(); - if (!tr) return; + if (!tr) { + if (refused) _dockFull(); + return; + } + if (tr.classList.contains('al-dock-empty')) { + commit({ srcId, srcFolderId: indent ? folderId : null, targetId: null, targetGroup: 'dock' }); + return; + } const into = !!tr.dataset.isFolder && canJoinFolder(itemType(srcId)) && dropInto; commit({ srcId, @@ -182,6 +228,7 @@ function wireTouchDrag(row, handle, { indent, folderId }) { indent: !!tr.dataset.indent, childIdx: tr.dataset.childIdx != null ? Number(tr.dataset.childIdx) : null, dropAbove: into ? false : dropAbove, + targetGroup: tr.dataset.group, }); }; const cancel = () => end(); @@ -200,19 +247,32 @@ function wireTouchDrag(row, handle, { indent, folderId }) { export function wireRowDrag(row, handle, { item, indent, folderId, childIdx }) { const dragData = indent ? 'child:' + folderId + ':' + item.id : 'top:' + item.id; + let frame = 0; row.addEventListener('dragstart', e => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragData); _dragType = item.type; - requestAnimationFrame(() => row.classList.add('dragging')); + _dragId = item.id; + _refused = false; + _dropped = false; + frame = requestAnimationFrame(() => row.classList.add('dragging')); }); row.addEventListener('dragend', () => { + cancelAnimationFrame(frame); row.classList.remove('dragging'); + if (_refused && !_dropped) _dockFull(); + _refused = false; + _dragId = null; _dragType = null; _marked = null; clearDragClasses(); }); row.addEventListener('dragover', e => { + if (!accepts(_dragId, row.dataset.group)) { + _refused = isApp(_dragId); + unmark(); + return; + } e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = row.getBoundingClientRect(); @@ -244,8 +304,34 @@ export function wireRowDrag(row, handle, { item, indent, folderId, childIdx }) { indent, childIdx, dropAbove, + targetGroup: row.dataset.group, }); }); wireTouchDrag(row, handle, { indent, folderId }); } + +/** The drop target shown in place of rows while the dock is empty. + @param {HTMLElement} zone @returns {void} */ +export function wireDockZone(zone) { + zone.dataset.group = 'dock'; + zone.addEventListener('dragover', e => { + if (!accepts(_dragId, 'dock')) { + _refused = isApp(_dragId); + unmark(); + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + mark(zone, 'drag-into'); + }); + zone.addEventListener('dragleave', e => { + if (!e.relatedTarget || !zone.contains(/** @type {Node} */ (e.relatedTarget))) clearDragClasses(zone); + }); + zone.addEventListener('drop', e => { + e.preventDefault(); + clearDragClasses(); + const drop = parseDragData(e.dataTransfer.getData('text/plain') || ''); + if (drop) commit({ ...drop, targetId: null, targetGroup: 'dock' }); + }); +} diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 00aff25f..0a64ed8f 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -13,13 +13,16 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; -import { reorderItems } from '/js/admin-logic.js?v=1cca36f5'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=8fa6fbae'; -import { paintIcon } from '/js/admin-shared.js?v=1c01be9a'; +import { reorderItems, topLevelRows } from '/js/admin-logic.js?v=67e24cd3'; +import { listGroup } from '/js/admin-drag-logic.js?v=1e25ae0a'; +import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=3f39ff43'; +import { DOCK_MAX } from '/js/limits.js?v=31048a24'; +import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; +import { paintIcon, toast } from '/js/admin-shared.js?v=d27d7ec7'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=705ca896'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=1cb745cf'; +import { sizeLabel } from '/js/admin-widget-form.js?v=718ad9b4'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; @@ -36,7 +39,7 @@ let _page = { @param {typeof _page} page @returns {void} */ export function initList(page) { _page = page; - initDrag(page.save); + initDrag(page.save, () => toast(t('app.dockFull', { max: formatNumber(DOCK_MAX) }))); } /* Constant markup only. No user data reaches these. */ @@ -64,6 +67,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n const _filtering = !!(filter.q || filter.type !== 'all'); row.draggable = !_filtering; row.dataset.itemId = item.id; + row.dataset.group = indent ? 'home' : listGroup(item); if (item.type === 'folder') row.dataset.isFolder = '1'; if (indent) { row.dataset.indent = '1'; @@ -78,8 +82,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n canUp = childIdx > 0; canDown = childIdx < n - 1; } else { - const inF = new Set(state.items.filter(i => i.type === 'folder').flatMap(ff => ff.children || [])); - const top = state.items.filter(it => it.type === 'folder' || !inF.has(it.id)); + const top = topLevelRows(state.items, listGroup(item) === 'dock'); const p = top.indexOf(item); canUp = p > 0; canDown = p < top.length - 1; @@ -144,7 +147,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n const pb = document.createElement('div'); pb.className = 'rpills'; const pills = []; - if (item.dock) pills.push(html`${t('app.dockPill')}`); + if (item.dock && _filtering) pills.push(html`${t('app.dockPill')}`); if (item.type === 'widget') pills.push(html`${t('type.widget')}`); if (item.type === 'folder') pills.push(html`${t('type.folder')}`); if (item.monitoring?.healthcheck?.enabled || item.container) @@ -169,28 +172,12 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n return b; }; if (!_filtering) ac.append(mkMove(-1, canUp), mkMove(1, canDown)); - if (item.system === 'settings') { - const hb = document.createElement('button'); - hb.className = 'btn bg sm'; - hb.textContent = t(item.hidden ? 'common.show' : 'common.hide'); - hb.dataset.act = 'hide'; - const lbl = t(item.hidden ? 'general.showSettingsAria' : 'general.hideSettingsAria'); - hb.title = lbl; - hb.setAttribute('aria-label', lbl); - hb.onclick = () => { - const before = snapshotItems(state.items); - item.hidden = !item.hidden; - _page.save(before); - }; - ac.append(hb); - } else { - const ed = document.createElement('button'); - ed.className = 'btn bg sm'; - ed.textContent = t('common.edit'); - ed.dataset.act = 'edit'; - ed.onclick = () => _page.openModal(idx); - ac.append(ed); - } + const ed = document.createElement('button'); + ed.className = 'btn bg sm'; + ed.textContent = t('common.edit'); + ed.dataset.act = 'edit'; + ed.onclick = () => _page.openModal(idx); + ac.append(ed); row.append(handle, ico, inf, pb, ac); wireRowDrag(row, handle, { item, indent, folderId, childIdx }); return row; @@ -202,7 +189,7 @@ const FALLBACK = { up: ['up', 'down'], down: ['down', 'up'] }; export function focusRow(itemId, act = 'edit') { const row = qa('.drow', el('al')).find(r => r.dataset.itemId === itemId); if (!row) return false; - const order = [...(FALLBACK[act] || [act]), 'edit', 'hide', 'toggle']; + const order = [...(FALLBACK[act] || [act]), 'edit', 'toggle']; return focusFirst(...order.map(a => row.querySelector(`[data-act="${a}"]`))); } @@ -218,6 +205,34 @@ export function render() { else if (act) focusRow(id, act); } +/** A heading and an empty group under it, added to the list. + @param {HTMLElement} l @param {'dock'|'home'} group @param {string} title @param {string} [count] + @returns {HTMLElement} the group */ +function listSection(l, group, title, count) { + const hdr = document.createElement('div'); + hdr.className = 'grp-hdr-row al-hdr'; + const h = document.createElement('p'); + h.className = 'grp-hdr'; + h.id = 'al-h-' + group; + h.setAttribute('role', 'heading'); + h.setAttribute('aria-level', '2'); + h.textContent = title; + hdr.appendChild(h); + if (count) { + const c = document.createElement('span'); + c.className = 'al-count'; + c.textContent = count; + hdr.appendChild(c); + } + const g = document.createElement('div'); + g.className = 'grp'; + g.dataset.group = group; + g.setAttribute('role', 'group'); + g.setAttribute('aria-labelledby', h.id); + l.append(hdr, g); + return g; +} + function draw(l) { const bar = el('al-filter'); const grp = el('al-grp'); @@ -252,18 +267,34 @@ function draw(l) { setHtml(l, html`

${t('list.noMatches')}

`); return; } - matches.forEach(item => l.appendChild(mkRow(item, state.items.indexOf(item)))); + const g = document.createElement('div'); + g.className = 'grp'; + matches.forEach(item => g.appendChild(mkRow(item, state.items.indexOf(item)))); + l.appendChild(g); return; } - const inFolder = new Set(state.items.filter(i => i.type === 'folder').flatMap(f => f.children || [])); - state.items.forEach((item, idx) => { - if (item.type !== 'folder' && inFolder.has(item.id)) return; - l.appendChild(mkRow(item, idx)); + const docked = topLevelRows(state.items, true); + const dockGrp = listSection(l, 'dock', t('home.dock'), `${formatNumber(docked.length)}/${formatNumber(DOCK_MAX)}`); + docked.forEach(item => dockGrp.appendChild(mkRow(item, state.items.indexOf(item)))); + if (!docked.length) { + const zone = document.createElement('div'); + zone.className = 'row al-dock-empty'; + zone.textContent = t('list.dockEmpty'); + dockGrp.appendChild(zone); + wireDockZone(zone); + } + const tip = document.createElement('p'); + tip.className = 'grp-tip'; + tip.textContent = t('list.dockTip'); + l.appendChild(tip); + const homeGrp = listSection(l, 'home', t('home.homeScreen')); + topLevelRows(state.items, false).forEach(item => { + homeGrp.appendChild(mkRow(item, state.items.indexOf(item))); if (item.type === 'folder' && !collapsedFolders.has(item.id)) { (item.children || []).forEach((childId, ci) => { const childItem = state.items.find(i => i.id === childId); - if (!childItem) return; - l.appendChild( + if (!childItem || listGroup(childItem) === 'dock') return; + homeGrp.appendChild( mkRow(childItem, state.items.indexOf(childItem), { indent: true, childIdx: ci, folderId: item.id }), ); }); @@ -274,7 +305,7 @@ function draw(l) { addRow.dataset.folderId = item.id; setHtml(addRow, html`+ ${t('folder.addAppToFolder')}`); addRow.onclick = () => _page.openFolderPicker(null, item.id); - l.appendChild(addRow); + homeGrp.appendChild(addRow); } }); } diff --git a/ui/js/admin-logic.js b/ui/js/admin-logic.js index 50569c2c..bbfa4ec0 100644 --- a/ui/js/admin-logic.js +++ b/ui/js/admin-logic.js @@ -229,7 +229,19 @@ export function groupBounds(field, size) { return { min, max }; } -/* Mutates `items` in place. */ +/** The top-level rows of one list group, in order. A docked app is drawn in + the dock group even when a folder lists it. + @param {any[]} items @param {boolean} docked @returns {any[]} */ +export function topLevelRows(items, docked) { + const inF = new Set(items.filter(i => i.type === 'folder').flatMap(ff => ff.children || [])); + return items.filter(it => { + const isDocked = it.type === 'app' && !!it.dock; + if (isDocked !== docked) return false; + return docked || it.type === 'folder' || !inF.has(it.id); + }); +} + +/* Mutates `items` in place. A top-level row moves only within its group. */ export function reorderItems(items, item, dir, { folderId = null, childIdx = null } = {}) { if (folderId != null) { const f = items.find(i => i.id === folderId); @@ -240,8 +252,7 @@ export function reorderItems(items, item, dir, { folderId = null, childIdx = nul [ch[childIdx], ch[j]] = [ch[j], ch[childIdx]]; return true; } - const inF = new Set(items.filter(i => i.type === 'folder').flatMap(ff => ff.children || [])); - const top = items.filter(it => it.type === 'folder' || !inF.has(it.id)); + const top = topLevelRows(items, item.type === 'app' && !!item.dock); const p = top.indexOf(item); const nb = top[p + dir]; if (!nb) return false; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 7fbea010..c00e7297 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=1c01be9a'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=d27d7ec7'; import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -9,12 +9,12 @@ import { needsCurrentPassword, createDirtyTracker, BLOCK, -} from '/js/admin-logic.js?v=1cca36f5'; +} from '/js/admin-logic.js?v=67e24cd3'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; -import { renderColorControl } from '/js/admin-color-control.js?v=4083ea3b'; +import { renderColorControl } from '/js/admin-color-control.js?v=b2285dc7'; import { BACKDROP } from '/js/background.js?v=25428bf2'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 4fd10a1f..647a9668 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,5 +1,5 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=1cca36f5'; +import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=67e24cd3'; import { el, isolate, q } from '/js/utils.js?v=705ca896'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=b0881957'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index e1d45f90..c1e81f19 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,9 +1,9 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=1c01be9a'; -import { createListbox } from '/js/listbox.js?v=f814170f'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=1807c371'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=d27d7ec7'; +import { createListbox } from '/js/listbox.js?v=bcf3b016'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=f438a5ed'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=1cca36f5'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=67e24cd3'; import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin.js b/ui/js/admin.js index e2664fee..260160ee 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,8 +1,14 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=f0da79cf'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5246ff39'; +import { + buildAppForm, + buildFolderForm, + buildSettingsTileForm, + captureActLabels, + serializeKvRows, +} from '/js/admin-app-form.js?v=ef927f27'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=4a0b94c5'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=411d6f37'; -import { resolveAdminSection } from '/js/admin-logic.js?v=1cca36f5'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=0edbb4c0'; +import { resolveAdminSection } from '/js/admin-logic.js?v=67e24cd3'; import { afterImport, buildAppItem, @@ -23,7 +29,7 @@ import { showBgFields, showWallpaperFile, wireUnsplashKey, -} from '/js/admin-settings.js?v=4b374391'; +} from '/js/admin-settings.js?v=304b2a7d'; import { apiGet, apiPost, @@ -37,13 +43,13 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=1c01be9a'; +} from '/js/admin-shared.js?v=d27d7ec7'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=1cb745cf'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=718ad9b4'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=f814170f'; +import { createListbox } from '/js/listbox.js?v=bcf3b016'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; @@ -344,6 +350,7 @@ function _renderEditBody(focusKind) { body.replaceChildren(); if (state.ctype === 'widget') buildWidgetForm(body, state._evItem); else if (state.ctype === 'folder') buildFolderForm(body, state._evItem); + else if (state.ctype === 'settings') buildSettingsTileForm(body, state._evItem); else buildAppForm(body, state._evItem); if (!state._evIsEdit) body.insertBefore(buildAddNewCard(), body.firstChild); setTimeout(() => { @@ -357,7 +364,7 @@ function openModal(idx) { const editing = idx != null ? state.items[idx] : null; state.eid = editing?.id ?? null; const item = editing ? structuredClone(editing) : null; - state.ctype = item?.type || 'app'; + state.ctype = item?.system === 'settings' ? 'settings' : item?.type || 'app'; state.siurl = item?.iconUrl || ''; state.scol = item ? item.color || 'dark' : 'auto'; state._customUrl = item?.url || ''; @@ -412,7 +419,7 @@ function openModal(idx) { const delBtn = el('ev-delete'); const saveBtn = el('ev-save'); if (delBtn) { - delBtn.classList.toggle('d-none', !isEdit); + delBtn.classList.toggle('d-none', !isEdit || state.ctype === 'settings'); delBtn.onclick = () => _evDelete(item, idx); } if (saveBtn) { @@ -660,6 +667,10 @@ async function doSave(orig) { }; if (Object.keys(ifo).length) item.iframe = ifo; } + } else if (state.ctype === 'settings') { + item = { ...orig, dock: inp('f-dock')?.checked || false }; + if (inp('f-shown')?.checked) delete item.hidden; + else item.hidden = true; } else if (state.ctype === 'folder') { const label = inp('f-fname')?.value?.trim(); if (!label) { @@ -719,6 +730,12 @@ async function doSave(orig) { } /* By id, never by position. */ const before = snapshotItems(state.items); + /* A docked app must leave its folders, or the dashboard draws it twice. */ + if (item.dock) { + state.items.forEach(f => { + if (f.type === 'folder') f.children = (f.children || []).filter(id => id !== item.id); + }); + } const { replaced } = upsertItem(state.items, state.eid, item); /* The editor stays open on a failed write, with the form intact. */ if (!(await saveOrRevert(before))) return; diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 67bc726a..10268c01 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -6,7 +6,7 @@ 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, singleChoice } from '/js/admin-logic.js?v=1cca36f5'; +import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=67e24cd3'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index f405f060..65504fe5 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=1c01be9a'; -import { createListbox } from '/js/listbox.js?v=f814170f'; -import { renderColorControl } from '/js/admin-color-control.js?v=4083ea3b'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=d27d7ec7'; +import { createListbox } from '/js/listbox.js?v=bcf3b016'; +import { renderColorControl } from '/js/admin-color-control.js?v=b2285dc7'; import { seedCarried, applyOptionSet, @@ -13,7 +13,7 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=1cca36f5'; +} from '/js/admin-logic.js?v=67e24cd3'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { qi } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; diff --git a/ui/test/admin-drag-logic.test.mjs b/ui/test/admin-drag-logic.test.mjs index 8d6504e5..85bb9c30 100644 --- a/ui/test/admin-drag-logic.test.mjs +++ b/ui/test/admin-drag-logic.test.mjs @@ -1,6 +1,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { canJoinFolder, dropTargetKind, applyDrop, folderRowZone } from '../js/admin-drag-logic.js'; +import { + canJoinFolder, + canDropInGroup, + dropTargetKind, + applyDrop, + folderRowZone, + listGroup, +} from '../js/admin-drag-logic.js'; /* a, b, w top level; f is a folder holding c. */ const model = () => [ @@ -198,3 +205,97 @@ test('a drop on a folder edge reorders instead of joining the folder', () => { assert.deepEqual(folder(items, 'f'), ['c']); assert.deepEqual(order(items), ['b', 'a', 'f', 'c', 'w']); }); + +/* d1, d2 docked; a, w and folder f (holding c) on the home screen. */ +const dockModel = () => [ + { id: 'd1', type: 'app', dock: true }, + { id: 'a', type: 'app' }, + { id: 'd2', type: 'app', dock: true }, + { id: 'f', type: 'folder', children: ['c'] }, + { id: 'c', type: 'app' }, + { id: 'w', type: 'widget' }, +]; +const dockOrder = items => items.filter(i => i.dock).map(i => i.id); +const drop = (items, d) => + applyDrop(items, { srcFolderId: null, targetFolderId: null, indent: false, childIdx: null, dockMax: 4, ...d }); + +test('only a docked app is drawn in the dock group', () => { + assert.equal(listGroup({ type: 'app', dock: true }), 'dock'); + assert.equal(listGroup({ type: 'app' }), 'home'); + assert.equal(listGroup({ type: 'widget', dock: true }), 'home'); + assert.equal(listGroup(undefined), 'home'); +}); + +test('the dock takes apps only, and only while it has room', () => { + const items = dockModel(); + const a = items[1]; + assert.equal(canDropInGroup(items, a, 'dock', 4), true); + assert.equal(canDropInGroup(items, a, 'dock', 2), false); + assert.equal(canDropInGroup(items, items[0], 'dock', 2), true); + assert.equal(canDropInGroup(items, items[5], 'dock', 4), false); + assert.equal(canDropInGroup(items, items[3], 'dock', 4), false); + assert.equal(canDropInGroup(items, items[5], 'home', 0), true); +}); + +test('dropping an app on a dock row docks it at that place', () => { + const items = dockModel(); + assert.equal(drop(items, { srcId: 'a', targetId: 'd2', dropAbove: true, targetGroup: 'dock' }), true); + assert.equal(items.find(i => i.id === 'a').dock, true); + assert.deepEqual(dockOrder(items), ['d1', 'a', 'd2']); +}); + +test('dropping a docked app on a home row undocks it', () => { + const items = dockModel(); + assert.equal(drop(items, { srcId: 'd1', targetId: 'w', dropAbove: false, targetGroup: 'home' }), true); + assert.equal(items.find(i => i.id === 'd1').dock, false); + assert.deepEqual(order(items).slice(-1), ['d1']); + assert.deepEqual(dockOrder(items), ['d2']); +}); + +test('reordering inside the dock keeps every app docked', () => { + const items = dockModel(); + drop(items, { srcId: 'd2', targetId: 'd1', dropAbove: true, targetGroup: 'dock' }); + assert.deepEqual(dockOrder(items), ['d2', 'd1']); +}); + +test('a full dock refuses a new app and leaves the list as it was', () => { + const items = dockModel(); + const before = JSON.stringify(items); + assert.equal(drop(items, { srcId: 'a', targetId: 'd1', dropAbove: true, targetGroup: 'dock', dockMax: 2 }), false); + assert.equal(JSON.stringify(items), before); +}); + +test('a widget or folder dropped on the dock is refused', () => { + for (const srcId of ['w', 'f']) { + const items = dockModel(); + const before = JSON.stringify(items); + assert.equal(drop(items, { srcId, targetId: 'd1', dropAbove: true, targetGroup: 'dock' }), false); + assert.equal(JSON.stringify(items), before); + } +}); + +test('the empty dock target docks the app at the end of the dock', () => { + const items = dockModel(); + assert.equal(drop(items, { srcId: 'a', targetId: null, targetGroup: 'dock' }), true); + assert.deepEqual(dockOrder(items), ['d1', 'd2', 'a']); +}); + +test('an app dragged from a folder into the dock leaves the folder', () => { + const items = dockModel(); + assert.equal( + drop(items, { srcId: 'c', srcFolderId: 'f', targetId: 'd1', dropAbove: false, targetGroup: 'dock' }), + true, + ); + assert.deepEqual(folder(items, 'f'), []); + assert.deepEqual(dockOrder(items), ['d1', 'c', 'd2']); +}); + +test('a docked app dropped into a folder undocks and joins it', () => { + const items = dockModel(); + assert.equal( + drop(items, { srcId: 'd1', targetId: 'f', targetIsFolder: true, dropAbove: false, targetGroup: 'home' }), + true, + ); + assert.equal(items.find(i => i.id === 'd1').dock, false); + assert.deepEqual(folder(items, 'f'), ['c', 'd1']); +}); diff --git a/ui/test/admin-logic.test.mjs b/ui/test/admin-logic.test.mjs index 922819fe..f4819b07 100644 --- a/ui/test/admin-logic.test.mjs +++ b/ui/test/admin-logic.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { reorderItems, + topLevelRows, isDockBlocked, nextActiveIndex, sameIconName, @@ -73,6 +74,46 @@ test('reorderItems skips items nested inside folders when ordering the top level ); }); +test('topLevelRows splits the list into the dock and the home screen', () => { + const items = [ + { id: 'd1', type: 'app', dock: true }, + { id: 'a', type: 'app' }, + { id: 'f', type: 'folder', children: ['c', 'd2'] }, + { id: 'c', type: 'app' }, + { id: 'd2', type: 'app', dock: true }, + { id: 'w', type: 'widget', dock: true }, + ]; + assert.deepEqual( + topLevelRows(items, true).map(i => i.id), + ['d1', 'd2'], + ); + assert.deepEqual( + topLevelRows(items, false).map(i => i.id), + ['a', 'f', 'w'], + ); +}); + +test('reorderItems moves a row only within its group', () => { + const items = [ + { id: 'd1', type: 'app', dock: true }, + { id: 'a', type: 'app' }, + { id: 'd2', type: 'app', dock: true }, + { id: 'b', type: 'app' }, + ]; + assert.equal(reorderItems(items, items[0], 1), true); + assert.deepEqual( + items.map(i => i.id), + ['d2', 'a', 'd1', 'b'], + ); + assert.equal(reorderItems(items, items[2], 1), false); + assert.equal(reorderItems(items, items[1], -1), false); + assert.equal(reorderItems(items, items[1], 1), true); + assert.deepEqual( + items.map(i => i.id), + ['d2', 'b', 'd1', 'a'], + ); +}); + test('reorderItems reorders a child within its folder', () => { const items = [{ id: 'f', type: 'folder', children: ['x', 'y', 'z'] }]; assert.equal(reorderItems(items, null, 1, { folderId: 'f', childIdx: 0 }), true); From 0a571542fa0d484e9b3bd5da7bb4d936867a1aee Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:33:15 -0400 Subject: [PATCH 2/2] Keep docked apps out of folders and fix the phone dock Settings link - Remove a dropped app from every folder before it joins one. - Skip docked folder children in the folder move arrows and the app count. - Take docked apps out of folders and hidden apps out of the dock on every list save. - Turn off and lock Show in Dock while the Settings tile is hidden, with a note. - Open the admin from a docked Settings tile in the phone layout. --- e2e/dashboard.spec.js | 14 ++++++++ e2e/dock-list.spec.js | 57 +++++++++++++++++++++++++++++-- ui/admin/index.html | 2 +- ui/i18n/de.json | 1 + ui/i18n/en.json | 1 + ui/i18n/es.json | 1 + ui/i18n/fa.json | 1 + ui/i18n/fr.json | 1 + ui/i18n/zh-Hans.json | 1 + ui/index.html | 2 +- ui/js/admin-app-form.js | 27 +++++++++++---- ui/js/admin-auth.js | 2 +- ui/js/admin-color-control.js | 4 +-- ui/js/admin-drag-logic.js | 15 ++++---- ui/js/admin-drag.js | 2 +- ui/js/admin-list.js | 26 ++++++++------ ui/js/admin-logic.js | 34 +++++++++++++++++- ui/js/admin-settings.js | 6 ++-- ui/js/admin-shared.js | 2 +- ui/js/admin-widget-form.js | 8 ++--- ui/js/admin.js | 28 +++++++-------- ui/js/dashboard.js | 2 +- ui/js/listbox.js | 2 +- ui/js/ui.js | 5 ++- ui/js/widget-config-form.js | 8 ++--- ui/test/admin-drag-logic.test.mjs | 20 +++++++++++ ui/test/admin-logic.test.mjs | 50 ++++++++++++++++++++++++++- 27 files changed, 257 insertions(+), 65 deletions(-) diff --git a/e2e/dashboard.spec.js b/e2e/dashboard.spec.js index fd81955b..83c6669f 100644 --- a/e2e/dashboard.spec.js +++ b/e2e/dashboard.spec.js @@ -241,3 +241,17 @@ test('resizing across the breakpoint switches the layout without a reload', asyn await expect(page.locator('#pages .dyn-mob-icon')).toHaveCount(0); expect(pageErrors, 'the rebuild threw').toEqual([]); }); + +test('a docked Settings tile opens the admin in both layouts', async ({ page, request }) => { + await seedConfig(request, { + items: [app('alpha', 'Alpha'), { id: 'settings', type: 'app', system: 'settings', label: 'Settings', dock: true }], + }); + await stubPolls(page); + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 844 }); + await page.goto('/'); + const link = page.locator('#dock a[aria-label="Settings"]'); + await expect(link).toHaveAttribute('href', '/admin/'); + await expect(link).not.toHaveAttribute('target', /.+/); + } +}); diff --git a/e2e/dock-list.spec.js b/e2e/dock-list.spec.js index b5c102f3..e9cc5304 100644 --- a/e2e/dock-list.spec.js +++ b/e2e/dock-list.spec.js @@ -19,12 +19,12 @@ async function dragRow(from, to, where = 'above') { await from.dragTo(to, { targetPosition: { x: box.width / 2, y: where === 'above' ? 4 : box.height - 4 } }); } -/** Ids of the docked items the server holds, in order. */ /** Flip a switch the way a person does. The checkbox itself is drawn hidden. */ function flip(page, id) { return page.locator(`label.tog:has(#${id})`).click(); } +/** Ids of the docked items the server holds, in order. */ async function savedDock(request) { return (await readConfig(request)).items.filter(i => i.dock).map(i => i.id); } @@ -181,12 +181,65 @@ test('Settings can be hidden and shown again from its editor', async ({ page, re expect((await readConfig(request)).items.find(i => i.system === 'settings').hidden).toBeUndefined(); }); +test('hiding Settings takes it out of the dock and locks Show in Dock', async ({ page, request }) => { + await seedConfig(request, { items: [app('alpha', 'Alpha')] }); + await openDashboardList(page); + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + const dock = page.locator('#f-dock'); + const tip = page.locator('#dock-tip'); + await expect(tip).toBeHidden(); + await flip(page, 'f-dock'); + await flip(page, 'f-shown'); + await expect(dock).not.toBeChecked(); + await expect(dock).toHaveAttribute('aria-disabled', 'true'); + await expect(dock).toHaveAttribute('aria-describedby', 'dock-tip'); + await expect(tip).toHaveText('Turn on Show Settings on dashboard to put it in the dock.'); + await page.locator('label.tog:has(#f-dock)').click({ force: true }); + await expect(dock).not.toBeChecked(); + await saveEditor(page); + const s = (await readConfig(request)).items.find(i => i.system === 'settings'); + expect(s.hidden).toBe(true); + expect(s.dock).toBe(false); + + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await flip(page, 'f-shown'); + await expect(dock).toHaveAttribute('aria-disabled', 'false'); + await expect(dock).not.toBeChecked(); + await expect(tip).toBeHidden(); +}); + +test('a save clears a docked app left in a folder and a hidden Settings left in the dock', async ({ + page, + request, +}) => { + await seedConfig(request, { + items: [ + { id: 'box', type: 'folder', label: 'Box', children: ['alpha', 'dee', 'bravo'] }, + app('alpha', 'Alpha'), + docked('dee', 'Dee'), + app('bravo', 'Bravo'), + app('charlie', 'Charlie'), + { id: 'settings', type: 'app', system: 'settings', label: 'Settings', hidden: true, dock: true }, + ], + }); + await openDashboardList(page); + await rowByName(page, 'Box').locator('.rnm').click(); + await expect(rowByName(page, 'Box').locator('.rmt')).toHaveText('2 apps'); + const saved = waitForSave(page); + await rowByName(page, 'Alpha').getByRole('button', { name: 'Move down: Alpha' }).click(); + expect((await saved).status()).toBe(200); + const cfg = await readConfig(request); + expect(cfg.items.find(i => i.id === 'box').children).toEqual(['bravo', 'alpha']); + expect(cfg.items.find(i => i.system === 'settings').dock).toBe(false); + expect(await savedDock(request)).toEqual(['dee']); +}); + test('Settings cannot join a full dock from its editor', async ({ page, request }) => { const four = ['d1', 'd2', 'd3', 'd4'].map(id => docked(id, id.toUpperCase())); await seedConfig(request, { items: four }); await openDashboardList(page); await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); - await expect(page.locator('#dock-full-tip')).toBeVisible(); + await expect(page.locator('#dock-tip')).toBeVisible(); await expect(page.locator('#f-dock')).toHaveAttribute('aria-disabled', 'true'); await page.locator('label.tog:has(#f-dock)').click({ force: true }); await expect(page.locator('#f-dock')).not.toBeChecked(); diff --git a/ui/admin/index.html b/ui/admin/index.html index 5d570497..18775d97 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -302,6 +302,6 @@

General

- + diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 7a5706b0..ced56f44 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -68,6 +68,7 @@ "logError": "Fehler", "passwordPh": "Neues Passwort (mind. 8 Zeichen)", "showSettingsAria": "Einstellungen im Dashboard anzeigen", + "dockNeedsShown": "Aktiviere „Einstellungen im Dashboard anzeigen“, um sie ins Dock zu legen.", "importJson": "Konfigurations-JSON importieren", "importForeignFiles": "Homepage- oder Dashy-YAML-Konfiguration importieren" }, diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 93e702b8..380ef50b 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -68,6 +68,7 @@ "logError": "Errors", "passwordPh": "New password (min 8 chars)", "showSettingsAria": "Show Settings on dashboard", + "dockNeedsShown": "Turn on Show Settings on dashboard to put it in the dock.", "importJson": "Import configuration JSON", "importForeignFiles": "Import Homepage or Dashy YAML config" }, diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 368d4bdb..b8ab7391 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -68,6 +68,7 @@ "logError": "Errores", "passwordPh": "Nueva contraseña (mín. 8 caracteres)", "showSettingsAria": "Mostrar Ajustes en el panel", + "dockNeedsShown": "Activa «Mostrar Ajustes en el panel» para añadir Ajustes al Dock.", "importJson": "Importar JSON de configuración", "importForeignFiles": "Importar configuración YAML de Homepage o Dashy" }, diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 60e1ab9a..e61d6955 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -68,6 +68,7 @@ "logError": "خطاها", "passwordPh": "رمز عبور جدید (حداقل ۸ کاراکتر)", "showSettingsAria": "نمایش تنظیمات در داشبورد", + "dockNeedsShown": "برای قرار دادن آن در داک، «نمایش تنظیمات در داشبورد» را روشن کنید.", "importJson": "وارد کردن JSON پیکربندی", "importForeignFiles": "وارد کردن پیکربندی YAML مربوط به Homepage یا Dashy" }, diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 0259eda5..e50bc459 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -68,6 +68,7 @@ "logError": "Erreurs", "passwordPh": "Nouveau mot de passe (8 caractères minimum)", "showSettingsAria": "Afficher les Réglages sur le tableau de bord", + "dockNeedsShown": "Activez « Afficher les Réglages sur le tableau de bord » pour les placer dans le Dock.", "importJson": "Importer le JSON de configuration", "importForeignFiles": "Importer une configuration YAML Homepage ou Dashy" }, diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 9c9a5850..8d2ed40f 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -68,6 +68,7 @@ "logError": "错误", "passwordPh": "新密码(至少 8 个字符)", "showSettingsAria": "在仪表板上显示设置", + "dockNeedsShown": "开启“在仪表板上显示设置”后,才能将其放入程序坞。", "importJson": "导入配置 JSON", "importForeignFiles": "导入 Homepage 或 Dashy 的 YAML 配置" }, diff --git a/ui/index.html b/ui/index.html index 37c48821..b797aa34 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index af25e9ad..2ea1fa2a 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -18,7 +18,7 @@ import { failureIsMissingApiPath, nextActiveIndex, sameIconName, -} from '/js/admin-logic.js?v=67e24cd3'; +} from '/js/admin-logic.js?v=546d9ed7'; import { t } from '/js/i18n.js?v=ed7c5565'; import { toast, @@ -31,10 +31,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=d27d7ec7'; -import { createListbox } from '/js/listbox.js?v=bcf3b016'; +} from '/js/admin-shared.js?v=d479609c'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=b2285dc7'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=43cc1b76'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; @@ -101,9 +101,24 @@ export function buildSettingsTileForm(body, item) {
${t('general.showSettingsAria')}${tog('f-shown', !item?.hidden, t('general.showSettingsAria'))}
${t('app.showInDock')}${tog('f-dock', !!item?.dock, t('app.showInDock'))}
- ${dockBlocked ? html`

${t('app.dockFull', { max: formatNumber(DOCK_MAX) })}

` : ''}`, +

`, ); - setTogDisabled(el('f-dock'), dockBlocked, 'dock-full-tip'); + const shown = inpById('f-shown'); + const dock = inpById('f-dock'); + const tip = el('dock-tip'); + const sync = () => { + const reason = !shown.checked + ? t('general.dockNeedsShown') + : dockBlocked + ? t('app.dockFull', { max: formatNumber(DOCK_MAX) }) + : ''; + if (!shown.checked) dock.checked = false; + tip.textContent = reason; + tip.classList.toggle('d-none', !reason); + setTogDisabled(dock, !!reason, 'dock-tip'); + }; + shown.addEventListener('change', sync); + sync(); } export function buildAppForm(body, item) { diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index eb80ad5d..6efa1451 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=d27d7ec7'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=d479609c'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 3e5b6d45..800ee20d 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,9 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=d27d7ec7'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=d479609c'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; -import { createListbox } from '/js/listbox.js?v=bcf3b016'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; 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']; diff --git a/ui/js/admin-drag-logic.js b/ui/js/admin-drag-logic.js index 03c3a1bd..421d16a7 100644 --- a/ui/js/admin-drag-logic.js +++ b/ui/js/admin-drag-logic.js @@ -42,6 +42,11 @@ export function dropTargetKind({ srcType, targetIsFolder = false, indent = false return 'reorder'; } +/* An app belongs to one folder, or the dashboard draws it twice. */ +function leaveFolders(items, id) { + for (const f of items) if (f.type === 'folder') f.children = (f.children || []).filter(c => c !== id); +} + /* Applies a drag move in place. `d` describes the dragged row and the target: srcId, srcFolderId the dragged row, and its folder or null targetId, targetFolderId the row dropped on, and its folder @@ -73,22 +78,18 @@ export function applyDrop(items, d) { items.push(src); return true; } - tf.children = (tf.children || []).filter(id => id !== src.id); + leaveFolders(items, src.id); if (!items.find(i => i.id === src.id)) items.push(src); tf.children.splice(d.childIdx, 0, src.id); } else if (kind === 'into-folder') { if (!items.find(i => i.id === src.id)) items.push(src); const tf = items.find(i => i.id === d.targetId); if (tf) { - tf.children = (tf.children || []).filter(id => id !== src.id); + leaveFolders(items, src.id); tf.children.push(src.id); } } else { - items - .filter(f => f.type === 'folder') - .forEach(f => { - f.children = (f.children || []).filter(id => id !== src.id); - }); + leaveFolders(items, src.id); if (!items.find(i => i.id === src.id)) items.push(src); const si2 = items.indexOf(src); if (si2 >= 0) items.splice(si2, 1); diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index acba18ad..a554c72a 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -9,7 +9,7 @@ directly. This module is the DOM half: what the pointer is doing, which edge it is near, and which classes say so. */ -import { applyDrop, canDropInGroup, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=1e25ae0a'; +import { applyDrop, canDropInGroup, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=1d1d553e'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 0a64ed8f..e02738ed 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -13,16 +13,16 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; -import { reorderItems, topLevelRows } from '/js/admin-logic.js?v=67e24cd3'; -import { listGroup } from '/js/admin-drag-logic.js?v=1e25ae0a'; -import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=3f39ff43'; +import { isShownChild, reorderItems, shownChildren, topLevelRows } from '/js/admin-logic.js?v=546d9ed7'; +import { listGroup } from '/js/admin-drag-logic.js?v=1d1d553e'; +import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=73c3c8da'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=d27d7ec7'; +import { paintIcon, toast } from '/js/admin-shared.js?v=d479609c'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=705ca896'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=718ad9b4'; +import { sizeLabel } from '/js/admin-widget-form.js?v=29567d90'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; @@ -77,10 +77,13 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n let canUp = false, canDown = false; if (folderId != null) { - const cf = state.items.find(i => i.id === folderId); - const n = (cf?.children || []).length; - canUp = childIdx > 0; - canDown = childIdx < n - 1; + const shown = shownChildren( + state.items, + state.items.find(i => i.id === folderId), + ); + const p = shown.indexOf(item); + canUp = p > 0; + canDown = p < shown.length - 1; } else { const top = topLevelRows(state.items, listGroup(item) === 'dock'); const p = top.indexOf(item); @@ -140,7 +143,8 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n const wt = item.widgetType || 'custom'; const wtLabel = state._widgetReg?.[wt]?.label || t('widgetCfg.typeCustom'); mt.textContent = t('widgetCfg.meta', { type: wtLabel, size: sizeLabel(item.widgetSize || 'medium') }); - } else if (item.type === 'folder') mt.textContent = t('folder.appsCount', { count: (item.children || []).length }); + } else if (item.type === 'folder') + mt.textContent = t('folder.appsCount', { count: shownChildren(state.items, item).length }); else if (item.system === 'settings') mt.textContent = t('home.opensSettings'); else mt.textContent = item.href || ''; inf.append(nm, mt); @@ -292,8 +296,8 @@ function draw(l) { homeGrp.appendChild(mkRow(item, state.items.indexOf(item))); if (item.type === 'folder' && !collapsedFolders.has(item.id)) { (item.children || []).forEach((childId, ci) => { + if (!isShownChild(state.items, childId)) return; const childItem = state.items.find(i => i.id === childId); - if (!childItem || listGroup(childItem) === 'dock') return; homeGrp.appendChild( mkRow(childItem, state.items.indexOf(childItem), { indent: true, childIdx: ci, folderId: item.id }), ); diff --git a/ui/js/admin-logic.js b/ui/js/admin-logic.js index bbfa4ec0..fd343699 100644 --- a/ui/js/admin-logic.js +++ b/ui/js/admin-logic.js @@ -241,13 +241,45 @@ export function topLevelRows(items, docked) { }); } +/** Whether a folder child id is drawn under its folder. A docked app is drawn + in the dock instead. + @param {any[]} items @param {string} id @returns {boolean} */ +export function isShownChild(items, id) { + const it = items.find(i => i.id === id); + return !!it && !(it.type === 'app' && it.dock); +} + +/** The folder's children drawn under it, in order. + @param {any[]} items @param {any} folder @returns {any[]} */ +export function shownChildren(items, folder) { + return (folder?.children || []).filter(id => isShownChild(items, id)).map(id => items.find(i => i.id === id)); +} + +/** Mutates `items` in place. A hidden app leaves the dock, and a docked app + leaves its folders. + @param {any[]} items @returns {void} */ +export function settleDock(items) { + const docked = new Set(); + for (const it of items) { + if (it.type !== 'app' || !it.dock) continue; + if (it.hidden) it.dock = false; + else docked.add(it.id); + } + for (const f of items) { + if (f.type === 'folder' && (f.children || []).some(id => docked.has(id))) { + f.children = f.children.filter(id => !docked.has(id)); + } + } +} + /* Mutates `items` in place. A top-level row moves only within its group. */ export function reorderItems(items, item, dir, { folderId = null, childIdx = null } = {}) { if (folderId != null) { const f = items.find(i => i.id === folderId); if (!f) return false; const ch = f.children || []; - const j = childIdx + dir; + let j = childIdx + dir; + while (j >= 0 && j < ch.length && !isShownChild(items, ch[j])) j += dir; if (j < 0 || j >= ch.length) return false; [ch[childIdx], ch[j]] = [ch[j], ch[childIdx]]; return true; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index c00e7297..054343b6 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=d27d7ec7'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=d479609c'; import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -9,12 +9,12 @@ import { needsCurrentPassword, createDirtyTracker, BLOCK, -} from '/js/admin-logic.js?v=67e24cd3'; +} from '/js/admin-logic.js?v=546d9ed7'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; -import { renderColorControl } from '/js/admin-color-control.js?v=b2285dc7'; +import { renderColorControl } from '/js/admin-color-control.js?v=43cc1b76'; import { BACKDROP } from '/js/background.js?v=25428bf2'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 647a9668..5d9d68c9 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,5 +1,5 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=67e24cd3'; +import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=546d9ed7'; import { el, isolate, q } from '/js/utils.js?v=705ca896'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=b0881957'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index c1e81f19..bf7d2e04 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,9 +1,9 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=d27d7ec7'; -import { createListbox } from '/js/listbox.js?v=bcf3b016'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=f438a5ed'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=d479609c'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=901eeaee'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=67e24cd3'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=546d9ed7'; import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin.js b/ui/js/admin.js index 260160ee..dac7905e 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,11 +4,11 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=ef927f27'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=4a0b94c5'; +} from '/js/admin-app-form.js?v=adc67cb0'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=6adaedef'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=0edbb4c0'; -import { resolveAdminSection } from '/js/admin-logic.js?v=67e24cd3'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=0371c091'; +import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=546d9ed7'; import { afterImport, buildAppItem, @@ -29,7 +29,7 @@ import { showBgFields, showWallpaperFile, wireUnsplashKey, -} from '/js/admin-settings.js?v=304b2a7d'; +} from '/js/admin-settings.js?v=b1897b60'; import { apiGet, apiPost, @@ -43,13 +43,13 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=d27d7ec7'; +} from '/js/admin-shared.js?v=d479609c'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=718ad9b4'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=29567d90'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=bcf3b016'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; @@ -172,6 +172,7 @@ async function writeItems(asked) { if (asked !== _replaced || JSON.stringify(full.items || []) !== _serverItems) { throw Object.assign(new Error('stale'), { status: 409 }); } + settleDock(state.items); const sent = JSON.stringify(state.items); full.items = JSON.parse(sent); const r = await apiPost('/api/config', full); @@ -668,8 +669,9 @@ async function doSave(orig) { if (Object.keys(ifo).length) item.iframe = ifo; } } else if (state.ctype === 'settings') { - item = { ...orig, dock: inp('f-dock')?.checked || false }; - if (inp('f-shown')?.checked) delete item.hidden; + const shown = !!inp('f-shown')?.checked; + item = { ...orig, dock: shown && !!inp('f-dock')?.checked }; + if (shown) delete item.hidden; else item.hidden = true; } else if (state.ctype === 'folder') { const label = inp('f-fname')?.value?.trim(); @@ -730,12 +732,6 @@ async function doSave(orig) { } /* By id, never by position. */ const before = snapshotItems(state.items); - /* A docked app must leave its folders, or the dashboard draws it twice. */ - if (item.dock) { - state.items.forEach(f => { - if (f.type === 'folder') f.children = (f.children || []).filter(id => id !== item.id); - }); - } const { replaced } = upsertItem(state.items, state.eid, item); /* The editor stays open on a failed write, with the form intact. */ if (!(await saveOrRevert(before))) return; diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 75a1d939..5c1b102b 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -46,7 +46,7 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=049178d3'; +} from '/js/ui.js?v=c66ab377'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=5c72bd4c'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=23793736'; diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 10268c01..9eb4af69 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -6,7 +6,7 @@ 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, singleChoice } from '/js/admin-logic.js?v=67e24cd3'; +import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=546d9ed7'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; diff --git a/ui/js/ui.js b/ui/js/ui.js index 8acfe974..f1d3ae93 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -797,7 +797,10 @@ export function buildMobile() { dk.replaceChildren(); observeGlass(dk, Math.round(38 * sc), 0.6); dock.forEach(item => { - const a = mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' }); + const a = + item.system === 'settings' + ? mk('a', { href: '/admin/' }) + : mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' }); a.className = 'dyn-dock-icon'; const nm = item.label || item.id; a.setAttribute('aria-label', nm); diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 65504fe5..599e1a6e 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=d27d7ec7'; -import { createListbox } from '/js/listbox.js?v=bcf3b016'; -import { renderColorControl } from '/js/admin-color-control.js?v=b2285dc7'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=d479609c'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; +import { renderColorControl } from '/js/admin-color-control.js?v=43cc1b76'; import { seedCarried, applyOptionSet, @@ -13,7 +13,7 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=67e24cd3'; +} from '/js/admin-logic.js?v=546d9ed7'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { qi } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; diff --git a/ui/test/admin-drag-logic.test.mjs b/ui/test/admin-drag-logic.test.mjs index 85bb9c30..0f6464a8 100644 --- a/ui/test/admin-drag-logic.test.mjs +++ b/ui/test/admin-drag-logic.test.mjs @@ -299,3 +299,23 @@ test('a docked app dropped into a folder undocks and joins it', () => { assert.equal(items.find(i => i.id === 'd1').dock, false); assert.deepEqual(folder(items, 'f'), ['c', 'd1']); }); + +test('a docked app still listed in a folder leaves it when dropped into another folder', () => { + const twoFolders = () => [ + { id: 'x', type: 'app', dock: true }, + { id: 'f1', type: 'folder', children: ['x'] }, + { id: 'f2', type: 'folder', children: ['y'] }, + { id: 'y', type: 'app' }, + ]; + let items = twoFolders(); + assert.equal(drop(items, { srcId: 'x', targetId: 'f2', targetIsFolder: true, targetGroup: 'home' }), true); + assert.deepEqual(folder(items, 'f1'), []); + assert.deepEqual(folder(items, 'f2'), ['y', 'x']); + items = twoFolders(); + assert.equal( + drop(items, { srcId: 'x', targetId: 'y', targetFolderId: 'f2', indent: true, childIdx: 0, targetGroup: 'home' }), + true, + ); + assert.deepEqual(folder(items, 'f1'), []); + assert.deepEqual(folder(items, 'f2'), ['x', 'y']); +}); diff --git a/ui/test/admin-logic.test.mjs b/ui/test/admin-logic.test.mjs index f4819b07..09fd1288 100644 --- a/ui/test/admin-logic.test.mjs +++ b/ui/test/admin-logic.test.mjs @@ -4,6 +4,8 @@ import assert from 'node:assert/strict'; import { reorderItems, topLevelRows, + shownChildren, + settleDock, isDockBlocked, nextActiveIndex, sameIconName, @@ -93,6 +95,49 @@ test('topLevelRows splits the list into the dock and the home screen', () => { ); }); +test('shownChildren leaves out a docked app and a missing id', () => { + const items = [ + { id: 'f', type: 'folder', children: ['c', 'd', 'gone', 'e'] }, + { id: 'c', type: 'app' }, + { id: 'd', type: 'app', dock: true }, + { id: 'e', type: 'app' }, + ]; + assert.deepEqual( + shownChildren(items, items[0]).map(i => i.id), + ['c', 'e'], + ); +}); + +test('a folder arrow steps over a docked child the list does not draw', () => { + const items = [ + { id: 'f', type: 'folder', children: ['c', 'd', 'e'] }, + { id: 'c', type: 'app' }, + { id: 'd', type: 'app', dock: true }, + { id: 'e', type: 'app' }, + ]; + assert.equal(reorderItems(items, items[1], 1, { folderId: 'f', childIdx: 0 }), true); + assert.deepEqual(items[0].children, ['e', 'd', 'c']); + items[0].children = ['c', 'd']; + assert.equal(reorderItems(items, items[1], 1, { folderId: 'f', childIdx: 0 }), false); + assert.deepEqual(items[0].children, ['c', 'd']); +}); + +test('settleDock takes docked apps out of folders and hidden apps out of the dock', () => { + const items = [ + { id: 'f', type: 'folder', children: ['c', 'd'] }, + { id: 'g', type: 'folder', children: ['d'] }, + { id: 'c', type: 'app' }, + { id: 'd', type: 'app', dock: true }, + { id: 'settings', type: 'app', system: 'settings', hidden: true, dock: true }, + ]; + settleDock(items); + assert.deepEqual(items[0].children, ['c']); + assert.deepEqual(items[1].children, []); + assert.equal(items[3].dock, true); + assert.equal(items[4].dock, false); + assert.equal(items[4].hidden, true); +}); + test('reorderItems moves a row only within its group', () => { const items = [ { id: 'd1', type: 'app', dock: true }, @@ -115,7 +160,10 @@ test('reorderItems moves a row only within its group', () => { }); test('reorderItems reorders a child within its folder', () => { - const items = [{ id: 'f', type: 'folder', children: ['x', 'y', 'z'] }]; + const items = [ + { id: 'f', type: 'folder', children: ['x', 'y', 'z'] }, + ...['x', 'y', 'z'].map(id => ({ id, type: 'app' })), + ]; assert.equal(reorderItems(items, null, 1, { folderId: 'f', childIdx: 0 }), true); assert.deepEqual(items[0].children, ['y', 'x', 'z']); assert.equal(reorderItems(items, null, -1, { folderId: 'f', childIdx: 0 }), false); // out of bounds