diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 8df5d3f..2924554 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index d4d5be5..85b4696 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 51ba908..3380c41 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -274,7 +274,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("function mediaIconSvg(type)", app_js) self.assertIn('const iconType = iconTypeForEntry(entry);', app_js) self.assertIn('function createMediaSlot(entry)', app_js) - self.assertNotIn("select-marker", app_js) + self.assertIn('function createSelectionSlot(pane, entry, index)', app_js) + self.assertIn('entry-select-slot', app_js) + self.assertIn('entry-select-toggle', app_js) + self.assertIn('toggleSelectionAtIndex(pane, selectedEntryFromItem(entry), index);', app_js) self.assertIn('function openSearch()', app_js) self.assertIn('async function submitSearch()', app_js) self.assertIn('async function openInfo()', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index c6cb557..17d701d 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -1237,6 +1237,45 @@ function createMediaSlot(entry) { return slot; } +function createSelectionSlot(pane, entry, index) { + const slot = document.createElement("span"); + slot.className = "entry-select-slot"; + + if (entry.isParent) { + const placeholder = document.createElement("span"); + placeholder.className = "entry-select-toggle is-disabled"; + placeholder.setAttribute("aria-hidden", "true"); + const indicator = document.createElement("span"); + indicator.className = "entry-select-indicator"; + placeholder.append(indicator); + slot.append(placeholder); + return slot; + } + + const button = document.createElement("button"); + button.type = "button"; + button.className = "entry-select-toggle"; + const selected = selectedPaths(pane).includes(entry.path); + if (selected) { + button.classList.add("is-selected"); + } + button.setAttribute("aria-label", `${selected ? "Deselect" : "Select"} ${entry.name}`); + button.setAttribute("aria-pressed", selected ? "true" : "false"); + const indicator = document.createElement("span"); + indicator.className = "entry-select-indicator"; + button.append(indicator); + button.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + setActivePane(pane); + paneState(pane).currentRowIndex = index; + toggleSelectionAtIndex(pane, selectedEntryFromItem(entry), index); + renderPaneItems(pane); + }; + slot.append(button); + return slot; +} + async function loadSettings() { const data = await apiRequest("GET", "/api/settings"); settingsState.showThumbnails = !!data.show_thumbnails; @@ -1546,7 +1585,7 @@ function formatFileSize(bytes) { return `${(bytes / (1024 ** 4)).toFixed(1)} TB`; } -function createBrowseItem(pane, entry, kind) { +function createBrowseItem(pane, entry, kind, index) { const li = document.createElement("li"); li.className = "selectable"; li.dataset.path = entry.path; @@ -1567,6 +1606,7 @@ function createBrowseItem(pane, entry, kind) { const name = document.createElement("span"); name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`; + name.append(createSelectionSlot(pane, { ...entry, kind }, index)); name.append(createMediaSlot({ ...entry, kind })); if (kind === "directory") { @@ -1680,6 +1720,7 @@ function renderPaneItems(pane) { }; const upNameCell = document.createElement("span"); upNameCell.className = "entry-name entry-dir"; + upNameCell.append(createSelectionSlot(pane, { ...entry, isParent: true }, index)); upNameCell.append(createMediaSlot({ name: "..", path: entry.path, kind: "directory" })); const upName = document.createElement("button"); upName.type = "button"; @@ -1705,7 +1746,7 @@ function renderPaneItems(pane) { return; } - const row = createBrowseItem(pane, entry, entry.kind); + const row = createBrowseItem(pane, entry, entry.kind, index); row.dataset.rowIndex = String(index); if (index === model.currentRowIndex) { row.classList.add("is-current-row"); @@ -1724,7 +1765,7 @@ function renderPaneItems(pane) { navigateTo(pane, entry.path); }; } - const fileName = row.querySelector(".entry-file span"); + const fileName = row.querySelector(".entry-file .entry-label"); if (fileName) { fileName.onclick = (ev) => { ev.stopPropagation(); diff --git a/webui/html/base.css b/webui/html/base.css index cfee420..60dce9a 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -346,6 +346,72 @@ button:disabled { min-width: 0; } +.entry-select-slot { + width: 18px; + min-width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.entry-select-toggle { + width: 18px; + min-width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: none; +} + +.entry-select-toggle:hover { + background: transparent; + border-color: transparent; + box-shadow: none; +} + +.entry-select-toggle.is-disabled { + pointer-events: none; +} + +.entry-select-indicator { + width: 16px; + height: 16px; + border-radius: 999px; + border: 1.5px solid color-mix(in srgb, var(--color-text-muted) 80%, transparent); + opacity: 0; + transition: opacity 100ms ease, border-color 100ms ease, background 100ms ease; + position: relative; +} + +.list li:hover .entry-select-indicator, +.entry-select-toggle:focus-visible .entry-select-indicator, +.entry-select-toggle.is-selected .entry-select-indicator { + opacity: 1; +} + +.entry-select-toggle.is-selected .entry-select-indicator { + border-color: var(--color-accent); + background: var(--color-accent); +} + +.entry-select-toggle.is-selected .entry-select-indicator::after { + content: "✓"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + line-height: 1; + color: var(--color-surface); +} + .entry-media-slot { width: 28px; min-width: 28px;