From c5aaa20ce20e2f9a8eeec06e67b93232fded1eea Mon Sep 17 00:00:00 2001 From: kodi Date: Mon, 9 Mar 2026 14:13:59 +0100 Subject: [PATCH] feat (ui): select files upgrade --- app/static/app.js | 168 ++++++++++++++++++++++++------------- app/static/styles.css | 51 ++++++++++- app/templates/index.html | 25 +++--- data/session_state.sqlite3 | Bin 102400 -> 106496 bytes 4 files changed, 170 insertions(+), 74 deletions(-) diff --git a/app/static/app.js b/app/static/app.js index e9fb639..13fb60c 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -13,6 +13,9 @@ modalFolders: [], modalFiles: [], modalSelectedFilePaths: new Set(), + modalKnownFiles: {}, + modalFileFilter: "", + modalVisibleFiles: [], syncScrolling: false, settings: { set_file_date_to_first_aired_date: false, @@ -62,13 +65,13 @@ fileModalTitle: document.getElementById("fileModalTitle"), closeFileModalBtn: document.getElementById("closeFileModalBtn"), modalRootSelect: document.getElementById("modalRootSelect"), - modalFolderSelect: document.getElementById("modalFolderSelect"), - modalSubpathInput: document.getElementById("modalSubpathInput"), modalRecursiveInput: document.getElementById("modalRecursiveInput"), - modalLoadFoldersBtn: document.getElementById("modalLoadFoldersBtn"), - modalLoadFilesBtn: document.getElementById("modalLoadFilesBtn"), + modalFileFilterInput: document.getElementById("modalFileFilterInput"), + modalSelectAllFilesBtn: document.getElementById("modalSelectAllFilesBtn"), + modalClearSelectionBtn: document.getElementById("modalClearSelectionBtn"), modalFoldersList: document.getElementById("modalFoldersList"), modalFilesList: document.getElementById("modalFilesList"), + modalSelectionCount: document.getElementById("modalSelectionCount"), modalAddSelectedFilesBtn: document.getElementById("modalAddSelectedFilesBtn"), }; @@ -361,7 +364,6 @@ populateDefaultRootOptions(); if (state.roots.length) { el.modalRootSelect.value = preferredRootId(); - el.modalSubpathInput.value = ""; await loadModalFolders(); } } @@ -471,12 +473,28 @@ } function openFileModal() { - state.modalSelectedFilePaths = new Set(); + const existingPaths = (state.selectedFiles || []) + .map((item) => (item.file && item.file.path) || "") + .filter((p) => typeof p === "string" && p.trim().length > 0); + state.modalSelectedFilePaths = new Set(existingPaths); + state.modalKnownFiles = {}; + (state.selectedFiles || []).forEach((item) => { + const path = (item.file && item.file.path) || ""; + if (!path) return; + state.modalKnownFiles[path] = { + path, + name: (item.file && item.file.name) || basename(path), + }; + }); + state.modalFileFilter = ""; el.fileModalTitle.textContent = "File Discovery"; el.modalAddSelectedFilesBtn.style.display = ""; - el.modalLoadFilesBtn.style.display = ""; + if (el.modalFileFilterInput) { + el.modalFileFilterInput.value = ""; + } el.fileModal.classList.remove("hidden"); el.fileModal.setAttribute("aria-hidden", "false"); + updateModalSelectionCount(); } function closeFileModal() { @@ -487,62 +505,54 @@ async function loadModalFolders() { const rootId = el.modalRootSelect.value; if (!rootId) throw new Error("No root selected"); - const subpath = (el.modalSubpathInput.value || "").trim(); + const subpath = ""; const data = await api( `/api/files/folders?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&limit=5000` ); state.modalFolders = data.items || []; - el.modalFolderSelect.innerHTML = ""; - const placeholder = document.createElement("option"); - placeholder.value = ""; - placeholder.textContent = state.modalFolders.length ? "Choose folder..." : "(No folders)"; - el.modalFolderSelect.appendChild(placeholder); - el.modalFoldersList.innerHTML = ""; state.modalFolders.forEach((folder) => { - const opt = document.createElement("option"); - opt.value = folder.subpath; - opt.textContent = folder.subpath || folder.name; - el.modalFolderSelect.appendChild(opt); - const li = document.createElement("li"); const left = document.createElement("span"); left.textContent = folder.subpath || folder.name; - const right = document.createElement("div"); - right.appendChild(makeBtn("Select", () => { - el.modalFolderSelect.value = folder.subpath; - el.modalSubpathInput.value = folder.subpath; - }, true)); li.appendChild(left); - li.appendChild(right); + li.addEventListener("click", () => withHandler(() => loadModalFiles(folder.subpath || ""), li)); el.modalFoldersList.appendChild(li); }); out("Folders loaded", data); } - async function loadModalFiles() { - const rootId = el.modalRootSelect.value; - const subpath = (el.modalFolderSelect.value || "").trim(); - if (!rootId) throw new Error("No root selected"); - if (!subpath) throw new Error("Choose a folder first"); + function updateModalSelectionCount() { + if (!el.modalSelectionCount) return; + const count = state.modalSelectedFilePaths.size; + const visibleCount = (state.modalVisibleFiles || []).length; + el.modalSelectionCount.textContent = `${count} selected (${visibleCount} visible)`; + if (el.modalAddSelectedFilesBtn) { + el.modalAddSelectedFilesBtn.disabled = count === 0; + } + } + + function renderModalFiles() { + const filter = (state.modalFileFilter || "").toLowerCase(); + const visible = (state.modalFiles || []).filter((file) => { + const text = `${file.relative_path || ""} ${file.name || ""}`.toLowerCase(); + return !filter || text.includes(filter); + }); + state.modalVisibleFiles = visible; - el.modalSubpathInput.value = subpath; - const recursive = el.modalRecursiveInput.checked ? "true" : "false"; - const data = await api( - `/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&recursive=${recursive}&limit=200` - ); - state.modalFiles = data.items || []; el.modalFilesList.innerHTML = ""; - state.modalSelectedFilePaths = new Set(); - - state.modalFiles.forEach((file) => { + visible.forEach((file) => { const li = document.createElement("li"); const left = document.createElement("span"); left.textContent = file.relative_path || file.name; - const right = document.createElement("div"); - right.appendChild(makeBtn("Toggle", () => { + li.appendChild(left); + + const isSelected = state.modalSelectedFilePaths.has(file.path); + if (isSelected) li.classList.add("selected"); + + li.addEventListener("click", () => { if (state.modalSelectedFilePaths.has(file.path)) { state.modalSelectedFilePaths.delete(file.path); li.classList.remove("selected"); @@ -550,28 +560,72 @@ state.modalSelectedFilePaths.add(file.path); li.classList.add("selected"); } - }, true)); - li.appendChild(left); - li.appendChild(right); + updateModalSelectionCount(); + }); el.modalFilesList.appendChild(li); }); + updateModalSelectionCount(); + } + + async function loadModalFiles(subpath) { + const rootId = el.modalRootSelect.value; + const chosenSubpath = (subpath || "").trim(); + if (!rootId) throw new Error("No root selected"); + if (!chosenSubpath) throw new Error("Choose a folder first"); + + const recursive = el.modalRecursiveInput.checked ? "true" : "false"; + const data = await api( + `/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(chosenSubpath)}&recursive=${recursive}&limit=200` + ); + state.modalFiles = data.items || []; + state.modalFiles.forEach((file) => { + const path = file.path || ""; + if (!path) return; + state.modalKnownFiles[path] = { + path, + name: file.name || basename(path), + }; + }); + renderModalFiles(); out("Discovered files", data); } async function addModalSelectedFiles() { - const selected = state.modalFiles.filter((f) => state.modalSelectedFilePaths.has(f.path)); - if (!selected.length) throw new Error("Select at least one file"); - const payload = selected.map((f) => ({ path: f.path, name: f.name })); - await api(q("/api/session/selected-files"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: payload }), - }); + const payload = Array.from(state.modalSelectedFilePaths) + .filter((path) => typeof path === "string" && path.trim().length > 0) + .map((path) => { + const known = state.modalKnownFiles[path] || {}; + return { + path, + name: known.name || basename(path), + }; + }); + + await api(q("/api/session/selected-files"), { method: "DELETE" }); + if (payload.length) { + await api(q("/api/session/selected-files"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: payload }), + }); + } await loadSelectedFiles(); closeFileModal(); } + function selectAllVisibleModalFiles() { + (state.modalVisibleFiles || []).forEach((file) => { + state.modalSelectedFilePaths.add(file.path); + }); + renderModalFiles(); + } + + function clearModalSelection() { + state.modalSelectedFilePaths.clear(); + renderModalFiles(); + } + async function reorderSelectedEpisodes(delta) { if (state.selectedPairIndex == null) throw new Error("Select an episode row first"); const from = state.selectedPairIndex; @@ -693,12 +747,12 @@ if (e.target === el.fileModal) closeFileModal(); }); el.modalRootSelect.addEventListener("change", () => withHandler(loadModalFolders, el.modalRootSelect)); - el.modalLoadFoldersBtn.addEventListener("click", () => withHandler(loadModalFolders, el.modalLoadFoldersBtn)); - el.modalLoadFilesBtn.addEventListener("click", () => withHandler(loadModalFiles, el.modalLoadFilesBtn)); - el.modalFolderSelect.addEventListener("change", () => { - const sub = (el.modalFolderSelect.value || "").trim(); - if (sub) el.modalSubpathInput.value = sub; + el.modalFileFilterInput.addEventListener("input", () => { + state.modalFileFilter = el.modalFileFilterInput.value || ""; + renderModalFiles(); }); + el.modalSelectAllFilesBtn.addEventListener("click", selectAllVisibleModalFiles); + el.modalClearSelectionBtn.addEventListener("click", clearModalSelection); el.modalAddSelectedFilesBtn.addEventListener("click", () => withHandler(addModalSelectedFiles, el.modalAddSelectedFilesBtn)); el.selectedEpisodesList.addEventListener("scroll", () => syncScroll(el.selectedEpisodesList, el.selectedFilesList)); diff --git a/app/static/styles.css b/app/static/styles.css index f9334c9..1ab175f 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -253,26 +253,69 @@ button.secondary { } .modal-card { - width: min(1100px, 96vw); + width: min(1400px, 90vw); + height: 80vh; background: #ffffff; border: 1px solid #d7dee9; border-radius: 10px; padding: 12px; + display: flex; + flex-direction: column; + min-height: 0; } .modal-head { + display: flex; + align-items: center; justify-content: space-between; + margin-bottom: 8px; +} + +.modal-root-row { + margin-bottom: 8px; } .modal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; - margin-top: 8px; + margin-top: 4px; + flex: 1; + min-height: 0; } -.modal-grid .list { - max-height: 280px; +.modal-pane { + display: flex; + flex-direction: column; + min-height: 0; +} + +.modal-pane .list { + flex: 1; + min-height: 0; + max-height: none; +} + +#modalFoldersList li, +#modalFilesList li { + cursor: pointer; +} + +.modal-files-tools { + margin-bottom: 8px; +} + +.modal-files-tools input[type="text"] { + flex: 1; + min-width: 180px; +} + +.modal-actions { + margin-top: 10px; + margin-bottom: 0; + justify-content: flex-end; + border-top: 1px solid #e4eaf2; + padding-top: 10px; } .settings-card { diff --git a/app/templates/index.html b/app/templates/index.html index d97f783..2a09e60 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -104,36 +104,35 @@