diff --git a/app/api/files.py b/app/api/files.py index 79b6c4e..165d4f1 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -34,7 +34,7 @@ def discover_files( def discover_folders( root_id: str = Query(..., min_length=1), subpath: str = Query(""), - limit: int = Query(500, ge=1, le=2000), + limit: int = Query(5000, ge=1, le=20000), ): service = FileDiscoveryService() try: diff --git a/app/services/file_discovery_service.py b/app/services/file_discovery_service.py index 646938b..7c7f43b 100644 --- a/app/services/file_discovery_service.py +++ b/app/services/file_discovery_service.py @@ -87,7 +87,7 @@ class FileDiscoveryService: self, root_id: str, subpath: str = "", - limit: int = 500, + limit: int = 5000, ) -> dict: root = self._get_root_by_id(root_id) target = self._resolve_target(root["path"], subpath) @@ -109,10 +109,12 @@ class FileDiscoveryService: break if not entry.is_dir(): continue + try: relative_to_root = entry.resolve().relative_to(root["path"]) except ValueError: continue + folders.append( { "name": entry.name, diff --git a/app/static/app.js b/app/static/app.js index 69ff796..64ebf68 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1,14 +1,18 @@ (function () { const STORAGE_KEY = "rename_mvp_session_id"; - const sessionId = initSessionId(); + const state = { - sessionId, + sessionId: initSessionId(), selectedSeries: null, episodes: [], + selectedEpisodes: [], + selectedFiles: [], + selectedPairIndex: null, roots: [], - folders: [], - discoveredFiles: [], - currentSubpath: "", + modalFolders: [], + modalFiles: [], + modalSelectedFilePaths: new Set(), + syncScrolling: false, }; const el = { @@ -20,23 +24,35 @@ seriesInfo: document.getElementById("seriesInfo"), refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"), episodesList: document.getElementById("episodesList"), + episodeMeta: document.getElementById("episodeMeta"), refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"), clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"), + episodeUpBtn: document.getElementById("episodeUpBtn"), + episodeDownBtn: document.getElementById("episodeDownBtn"), + episodeRemoveBtn: document.getElementById("episodeRemoveBtn"), selectedEpisodesList: document.getElementById("selectedEpisodesList"), - rootsSelect: document.getElementById("rootsSelect"), - foldersSelect: document.getElementById("foldersSelect"), - refreshRootsBtn: document.getElementById("refreshRootsBtn"), - subpathInput: document.getElementById("subpathInput"), - loadFoldersBtn: document.getElementById("loadFoldersBtn"), - loadFilesBtn: document.getElementById("loadFilesBtn"), - recursiveInput: document.getElementById("recursiveInput"), - discoveredFilesList: document.getElementById("discoveredFilesList"), + fileMeta: document.getElementById("fileMeta"), + selectFilesBtn: document.getElementById("selectFilesBtn"), refreshSelectedFilesBtn: document.getElementById("refreshSelectedFilesBtn"), clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"), + fileUpBtn: document.getElementById("fileUpBtn"), + fileDownBtn: document.getElementById("fileDownBtn"), + fileRemoveBtn: document.getElementById("fileRemoveBtn"), selectedFilesList: document.getElementById("selectedFilesList"), - mappingPreviewBtn: document.getElementById("mappingPreviewBtn"), filenamePreviewBtn: document.getElementById("filenamePreviewBtn"), renameExecuteBtn: document.getElementById("renameExecuteBtn"), + fileModal: document.getElementById("fileModal"), + 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"), + modalFoldersList: document.getElementById("modalFoldersList"), + modalFilesList: document.getElementById("modalFilesList"), + modalAddSelectedFilesBtn: document.getElementById("modalAddSelectedFilesBtn"), }; function initSessionId() { @@ -48,7 +64,9 @@ } function q(path) { - return path.includes("?") ? `${path}&session_id=${encodeURIComponent(state.sessionId)}` : `${path}?session_id=${encodeURIComponent(state.sessionId)}`; + return path.includes("?") + ? `${path}&session_id=${encodeURIComponent(state.sessionId)}` + : `${path}?session_id=${encodeURIComponent(state.sessionId)}`; } async function api(path, options = {}) { @@ -70,7 +88,7 @@ el.outputBox.textContent = `${label}\n${JSON.stringify(payload, null, 2)}`; } - function makeActionButton(label, handler, secondary) { + function makeBtn(label, handler, secondary) { const btn = document.createElement("button"); btn.textContent = label; if (secondary) btn.className = "secondary"; @@ -78,27 +96,97 @@ return btn; } - function setLoading(btn, loading) { - if (!btn) return; - btn.disabled = loading; + function setBusy(btn, busy) { + if (btn) btn.disabled = busy; + } + + function updateMeta() { + const epCount = state.selectedEpisodes.length; + const fileCount = state.selectedFiles.length; + const mismatch = epCount !== fileCount; + const mismatchText = mismatch ? " mismatch" : " aligned"; + + el.episodeMeta.innerHTML = `Rows: ${epCount}`; + el.fileMeta.innerHTML = `Rows: ${fileCount} ${mismatchText}`; + } + + function selectPair(index) { + state.selectedPairIndex = index; + renderSelectedEpisodes(); + renderSelectedFiles(); + } + + function syncScroll(source, target) { + if (state.syncScrolling) return; + state.syncScrolling = true; + target.scrollTop = source.scrollTop; + requestAnimationFrame(() => { + state.syncScrolling = false; + }); + } + + function renderSelectedEpisodes() { + el.selectedEpisodesList.innerHTML = ""; + state.selectedEpisodes.forEach((item, idx) => { + const li = document.createElement("li"); + if (state.selectedPairIndex === idx) li.classList.add("selected"); + + const left = document.createElement("span"); + const badge = document.createElement("span"); + badge.className = "badge"; + badge.textContent = `#${idx + 1}`; + left.appendChild(badge); + left.appendChild(document.createTextNode(item.episode.label || item.episode.title || "(episode)")); + + const right = document.createElement("div"); + right.appendChild(makeBtn("Select", () => selectPair(idx), true)); + + li.appendChild(left); + li.appendChild(right); + li.addEventListener("click", () => selectPair(idx)); + el.selectedEpisodesList.appendChild(li); + }); + updateMeta(); + } + + function renderSelectedFiles() { + el.selectedFilesList.innerHTML = ""; + state.selectedFiles.forEach((item, idx) => { + const li = document.createElement("li"); + if (state.selectedPairIndex === idx) li.classList.add("selected"); + + const left = document.createElement("span"); + const badge = document.createElement("span"); + badge.className = "badge"; + badge.textContent = `#${idx + 1}`; + left.appendChild(badge); + left.appendChild(document.createTextNode(item.file.path || item.file.name || "(file)")); + + const right = document.createElement("div"); + right.appendChild(makeBtn("Select", () => selectPair(idx), true)); + + li.appendChild(left); + li.appendChild(right); + li.addEventListener("click", () => selectPair(idx)); + el.selectedFilesList.appendChild(li); + }); + updateMeta(); } async function loadRoots() { const data = await api("/api/files/roots"); state.roots = data.items || []; - el.rootsSelect.innerHTML = ""; - for (const root of state.roots) { + el.modalRootSelect.innerHTML = ""; + state.roots.forEach((root) => { const opt = document.createElement("option"); opt.value = root.id; opt.textContent = `${root.id}: ${root.path}`; - el.rootsSelect.appendChild(opt); + el.modalRootSelect.appendChild(opt); + }); + if (state.roots.length) { + el.modalSubpathInput.value = ""; + await loadModalFolders(); } - if (state.roots.length > 0) { - state.currentSubpath = ""; - el.subpathInput.value = ""; - await loadFolders(); - } - out("Roots loaded", data); } async function doSearch() { @@ -109,14 +197,13 @@ (data.items || []).forEach((item) => { const li = document.createElement("li"); const left = document.createElement("span"); - left.textContent = item.display_name || item.name || "(no name)"; + left.textContent = item.display_name || item.name || "(series)"; const right = document.createElement("div"); - const btn = makeActionButton("Select", async () => { + right.appendChild(makeBtn("Select", async () => { state.selectedSeries = item; el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; await loadEpisodes(); - }); - right.appendChild(btn); + })); li.appendChild(left); li.appendChild(right); el.searchResults.appendChild(li); @@ -131,13 +218,12 @@ const data = await api(`/api/tvdb/series/${encodeURIComponent(state.selectedSeries.id)}/episodes?order_type=aired`); state.episodes = data.items || []; el.episodesList.innerHTML = ""; - state.episodes.forEach((episode) => { const li = document.createElement("li"); const left = document.createElement("span"); left.textContent = episode.label || `${episode.season_number}x${episode.episode_number} ${episode.title}`; const right = document.createElement("div"); - const btn = makeActionButton("Add", async () => { + right.appendChild(makeBtn("Add", async () => { const payload = { id: episode.id, series: state.selectedSeries.name, @@ -148,15 +234,13 @@ aired: episode.aired, label: episode.label, }; - const res = await api(q("/api/session/selected-episodes"), { + await api(q("/api/session/selected-episodes"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: [payload] }), }); await loadSelectedEpisodes(); - out("Episode added", res); - }); - right.appendChild(btn); + })); li.appendChild(left); li.appendChild(right); el.episodesList.appendChild(li); @@ -166,159 +250,179 @@ async function loadSelectedEpisodes() { const data = await api(q("/api/session/selected-episodes")); - el.selectedEpisodesList.innerHTML = ""; - (data.items || []).forEach((item, idx) => { - const li = document.createElement("li"); - const left = document.createElement("span"); - left.textContent = item.episode.label || item.episode.title || `Episode #${idx + 1}`; - const right = document.createElement("div"); - right.appendChild(makeActionButton("Up", async () => { - if (idx === 0) return; - await api(q("/api/session/selected-episodes/reorder"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ from_index: idx, to_index: idx - 1 }), - }); - await loadSelectedEpisodes(); - }, true)); - right.appendChild(makeActionButton("Down", async () => { - if (idx >= data.items.length - 1) return; - await api(q("/api/session/selected-episodes/reorder"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ from_index: idx, to_index: idx + 1 }), - }); - await loadSelectedEpisodes(); - }, true)); - right.appendChild(makeActionButton("Remove", async () => { - await api(q(`/api/session/selected-episodes/${item.selection_id}`), { method: "DELETE" }); - await loadSelectedEpisodes(); - }, true)); - li.appendChild(left); - li.appendChild(right); - el.selectedEpisodesList.appendChild(li); - }); + state.selectedEpisodes = data.items || []; + if (state.selectedPairIndex != null && state.selectedPairIndex >= state.selectedEpisodes.length) { + state.selectedPairIndex = state.selectedEpisodes.length - 1; + } + renderSelectedEpisodes(); return data; } - async function loadFolders() { - const rootId = el.rootsSelect.value; - if (!rootId) throw new Error("No root selected"); - const subpath = (el.subpathInput.value || "").trim(); - state.currentSubpath = subpath; - const data = await api( - `/api/files/folders?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&limit=500` - ); - state.folders = data.items || []; - el.foldersSelect.innerHTML = ""; + async function loadSelectedFiles() { + const data = await api(q("/api/session/selected-files")); + state.selectedFiles = data.items || []; + if (state.selectedPairIndex != null && state.selectedPairIndex >= state.selectedFiles.length) { + state.selectedPairIndex = state.selectedFiles.length - 1; + } + renderSelectedFiles(); + return data; + } + function openFileModal() { + state.modalSelectedFilePaths = new Set(); + el.fileModalTitle.textContent = "File Discovery"; + el.modalAddSelectedFilesBtn.style.display = ""; + el.modalLoadFilesBtn.style.display = ""; + el.fileModal.classList.remove("hidden"); + el.fileModal.setAttribute("aria-hidden", "false"); + } + + function closeFileModal() { + el.fileModal.classList.add("hidden"); + el.fileModal.setAttribute("aria-hidden", "true"); + } + + async function loadModalFolders() { + const rootId = el.modalRootSelect.value; + if (!rootId) throw new Error("No root selected"); + const subpath = (el.modalSubpathInput.value || "").trim(); + 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.folders.length ? "Choose folder..." : "(No folders)"; - el.foldersSelect.appendChild(placeholder); + placeholder.textContent = state.modalFolders.length ? "Choose folder..." : "(No folders)"; + el.modalFolderSelect.appendChild(placeholder); - state.folders.forEach((folder) => { + el.modalFoldersList.innerHTML = ""; + state.modalFolders.forEach((folder) => { const opt = document.createElement("option"); opt.value = folder.subpath; opt.textContent = folder.subpath || folder.name; - el.foldersSelect.appendChild(opt); + 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); + el.modalFoldersList.appendChild(li); }); out("Folders loaded", data); } - async function discoverFiles() { - const rootId = el.rootsSelect.value; + async function loadModalFiles() { + const rootId = el.modalRootSelect.value; + const subpath = (el.modalFolderSelect.value || "").trim(); if (!rootId) throw new Error("No root selected"); - const selectedFolder = (el.foldersSelect.value || "").trim(); - if (!selectedFolder) throw new Error("Choose a folder first"); - el.subpathInput.value = selectedFolder; - state.currentSubpath = selectedFolder; - const subpath = encodeURIComponent(selectedFolder); - const recursive = el.recursiveInput.checked ? "true" : "false"; - const data = await api(`/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${subpath}&recursive=${recursive}&limit=200`); - state.discoveredFiles = data.items || []; - el.discoveredFilesList.innerHTML = ""; + if (!subpath) throw new Error("Choose a folder first"); - state.discoveredFiles.forEach((file) => { + 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) => { const li = document.createElement("li"); const left = document.createElement("span"); left.textContent = file.relative_path || file.name; const right = document.createElement("div"); - right.appendChild(makeActionButton("Add", async () => { - const payload = { path: file.path, name: file.name }; - const res = await api(q("/api/session/selected-files"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: [payload] }), - }); - await loadSelectedFiles(); - out("File added", res); - })); - li.appendChild(left); - li.appendChild(right); - el.discoveredFilesList.appendChild(li); - }); - out("Discover result", data); - } - - async function loadSelectedFiles() { - const data = await api(q("/api/session/selected-files")); - el.selectedFilesList.innerHTML = ""; - (data.items || []).forEach((item, idx) => { - const li = document.createElement("li"); - const left = document.createElement("span"); - left.textContent = item.file.path || item.file.name || `File #${idx + 1}`; - const right = document.createElement("div"); - right.appendChild(makeActionButton("Up", async () => { - if (idx === 0) return; - await api(q("/api/session/selected-files/reorder"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ from_index: idx, to_index: idx - 1 }), - }); - await loadSelectedFiles(); - }, true)); - right.appendChild(makeActionButton("Down", async () => { - if (idx >= data.items.length - 1) return; - await api(q("/api/session/selected-files/reorder"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ from_index: idx, to_index: idx + 1 }), - }); - await loadSelectedFiles(); - }, true)); - right.appendChild(makeActionButton("Remove", async () => { - await api(q(`/api/session/selected-files/${item.selection_id}`), { method: "DELETE" }); - await loadSelectedFiles(); + right.appendChild(makeBtn("Toggle", () => { + if (state.modalSelectedFilePaths.has(file.path)) { + state.modalSelectedFilePaths.delete(file.path); + li.classList.remove("selected"); + } else { + state.modalSelectedFilePaths.add(file.path); + li.classList.add("selected"); + } }, true)); li.appendChild(left); li.appendChild(right); - el.selectedFilesList.appendChild(li); + el.modalFilesList.appendChild(li); }); - return data; + + out("Discovered files", data); } - async function callPreview(path, label) { - const data = await api(q(path)); - out(label, 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 }), + }); + await loadSelectedFiles(); + closeFileModal(); + } + + async function reorderSelectedEpisodes(delta) { + if (state.selectedPairIndex == null) throw new Error("Select an episode row first"); + const from = state.selectedPairIndex; + const to = from + delta; + if (to < 0 || to >= state.selectedEpisodes.length) return; + await api(q("/api/session/selected-episodes/reorder"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from_index: from, to_index: to }), + }); + state.selectedPairIndex = to; + await loadSelectedEpisodes(); + } + + async function reorderSelectedFiles(delta) { + if (state.selectedPairIndex == null) throw new Error("Select a file row first"); + const from = state.selectedPairIndex; + const to = from + delta; + if (to < 0 || to >= state.selectedFiles.length) return; + await api(q("/api/session/selected-files/reorder"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from_index: from, to_index: to }), + }); + state.selectedPairIndex = to; + await loadSelectedFiles(); + } + + async function removeSelectedEpisode() { + if (state.selectedPairIndex == null) throw new Error("Select an episode row first"); + const item = state.selectedEpisodes[state.selectedPairIndex]; + if (!item) return; + await api(q(`/api/session/selected-episodes/${item.selection_id}`), { method: "DELETE" }); + await loadSelectedEpisodes(); + } + + async function removeSelectedFile() { + if (state.selectedPairIndex == null) throw new Error("Select a file row first"); + const item = state.selectedFiles[state.selectedPairIndex]; + if (!item) return; + await api(q(`/api/session/selected-files/${item.selection_id}`), { method: "DELETE" }); + await loadSelectedFiles(); } async function executeRename() { - const data = await api(q("/api/session/rename-execute") + "&confirm=true", { - method: "POST", - }); + const data = await api(q("/api/session/rename-execute") + "&confirm=true", { method: "POST" }); if (data.executed) { out("Rename execute: success", data); - // Keep UI in sync with renamed destinations using existing selected-files endpoints. const renamedFiles = (data.items || []) - .filter((item) => item.status === "renamed") - .map((item) => ({ - path: item.destination_path, - name: item.proposed_filename, - })); + .filter((i) => i.status === "renamed") + .map((i) => ({ path: i.destination_path, name: i.proposed_filename })); await api(q("/api/session/selected-files"), { method: "DELETE" }); - if (renamedFiles.length > 0) { + if (renamedFiles.length) { await api(q("/api/session/selected-files"), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -328,67 +432,79 @@ await loadSelectedFiles(); return; } - out("Rename execute: preflight failed", data); } async function withHandler(fn, btn) { try { - setLoading(btn, true); + setBusy(btn, true); await fn(); } catch (err) { out("Error", { detail: err.message || String(err) }); } finally { - setLoading(btn, false); + setBusy(btn, false); } } function bindEvents() { el.searchBtn.addEventListener("click", () => withHandler(doSearch, el.searchBtn)); el.refreshEpisodesBtn.addEventListener("click", () => withHandler(loadEpisodes, el.refreshEpisodesBtn)); + el.refreshSelectedEpisodesBtn.addEventListener("click", () => withHandler(loadSelectedEpisodes, el.refreshSelectedEpisodesBtn)); el.clearSelectedEpisodesBtn.addEventListener("click", () => withHandler(async () => { - const res = await api(q("/api/session/selected-episodes"), { method: "DELETE" }); + await api(q("/api/session/selected-episodes"), { method: "DELETE" }); + state.selectedPairIndex = null; await loadSelectedEpisodes(); - out("Selected episodes cleared", res); }, el.clearSelectedEpisodesBtn) ); - el.refreshRootsBtn.addEventListener("click", () => withHandler(loadRoots, el.refreshRootsBtn)); - el.rootsSelect.addEventListener("change", () => withHandler(async () => { - state.currentSubpath = ""; - el.subpathInput.value = ""; - await loadFolders(); - el.discoveredFilesList.innerHTML = ""; - }, el.rootsSelect)); - el.loadFoldersBtn.addEventListener("click", () => withHandler(loadFolders, el.loadFoldersBtn)); - el.loadFilesBtn.addEventListener("click", () => withHandler(discoverFiles, el.loadFilesBtn)); - el.foldersSelect.addEventListener("change", () => { - const chosen = (el.foldersSelect.value || "").trim(); - if (chosen) { - el.subpathInput.value = chosen; - } - }); + el.episodeUpBtn.addEventListener("click", () => withHandler(() => reorderSelectedEpisodes(-1), el.episodeUpBtn)); + el.episodeDownBtn.addEventListener("click", () => withHandler(() => reorderSelectedEpisodes(1), el.episodeDownBtn)); + el.episodeRemoveBtn.addEventListener("click", () => withHandler(removeSelectedEpisode, el.episodeRemoveBtn)); + + el.selectFilesBtn.addEventListener("click", () => withHandler(async () => { + openFileModal(); + await loadRoots(); + }, el.selectFilesBtn)); el.refreshSelectedFilesBtn.addEventListener("click", () => withHandler(loadSelectedFiles, el.refreshSelectedFilesBtn)); el.clearSelectedFilesBtn.addEventListener("click", () => withHandler(async () => { - const res = await api(q("/api/session/selected-files"), { method: "DELETE" }); + await api(q("/api/session/selected-files"), { method: "DELETE" }); + state.selectedPairIndex = null; await loadSelectedFiles(); - out("Selected files cleared", res); }, el.clearSelectedFilesBtn) ); - el.mappingPreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/mapping-preview", "Mapping preview"), el.mappingPreviewBtn)); - el.filenamePreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/filename-preview", "Filename preview"), el.filenamePreviewBtn)); + el.fileUpBtn.addEventListener("click", () => withHandler(() => reorderSelectedFiles(-1), el.fileUpBtn)); + el.fileDownBtn.addEventListener("click", () => withHandler(() => reorderSelectedFiles(1), el.fileDownBtn)); + el.fileRemoveBtn.addEventListener("click", () => withHandler(removeSelectedFile, el.fileRemoveBtn)); + + el.filenamePreviewBtn.addEventListener("click", () => withHandler(async () => out("Filename preview", await api(q("/api/session/filename-preview"))), el.filenamePreviewBtn)); el.renameExecuteBtn.addEventListener("click", () => withHandler(executeRename, el.renameExecuteBtn)); + + el.closeFileModalBtn.addEventListener("click", closeFileModal); + el.fileModal.addEventListener("click", (e) => { + 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.modalAddSelectedFilesBtn.addEventListener("click", () => withHandler(addModalSelectedFiles, el.modalAddSelectedFilesBtn)); + + el.selectedEpisodesList.addEventListener("scroll", () => syncScroll(el.selectedEpisodesList, el.selectedFilesList)); + el.selectedFilesList.addEventListener("scroll", () => syncScroll(el.selectedFilesList, el.selectedEpisodesList)); } async function init() { el.sessionMeta.textContent = `session_id: ${state.sessionId}`; - el.recursiveInput.checked = true; + el.modalRecursiveInput.checked = true; bindEvents(); - await loadRoots(); await loadSelectedEpisodes(); await loadSelectedFiles(); + await loadRoots(); } init().catch((err) => out("Init error", { detail: err.message || String(err) })); diff --git a/app/static/styles.css b/app/static/styles.css index 36b3ac1..4211bed 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -41,6 +41,9 @@ body { border: 1px solid #d7dee9; border-radius: 8px; padding: 10px; + min-height: 420px; + display: flex; + flex-direction: column; } .panel h2 { @@ -98,6 +101,101 @@ button.secondary { border-radius: 6px; } +.linked-list-wrap { + flex: 1; + min-height: 240px; + max-height: 440px; +} + +.linked-list { + height: 100%; +} + +.badge { + display: inline-block; + font-size: 11px; + font-weight: 700; + color: #0f172a; + background: #dbeafe; + border: 1px solid #bfdbfe; + border-radius: 999px; + padding: 1px 6px; + margin-right: 6px; +} + +.list li.selected { + background: #e0f2fe; +} + +.panel-footer { + position: sticky; + bottom: 0; + background: #ffffff; + border-top: 1px solid #e4eaf2; + padding-top: 8px; + margin-top: 8px; + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; +} + +.advanced-row { + margin-top: 8px; + justify-content: flex-end; +} + +.mismatch { + color: #b91c1c; + font-weight: 700; +} + +.modal { + position: fixed; + inset: 0; + background: rgba(2, 6, 23, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal.hidden { + display: none; +} + +.modal-card { + width: min(1100px, 96vw); + background: #ffffff; + border: 1px solid #d7dee9; + border-radius: 10px; + padding: 12px; +} + +.modal-head { + justify-content: space-between; +} + +.modal-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 8px; +} + +.modal-grid .list { + max-height: 280px; +} + +#panelSelectedEpisodes .panel-footer button:first-child, +#panelSelectedFiles .panel-footer button:last-child, +#panelSelectedFiles .advanced-row button:last-child { + border-color: #0b3a6e; + background: #0b3a6e; + color: #ffffff; +} + .list li { display: flex; justify-content: space-between; @@ -117,10 +215,6 @@ button.secondary { margin-bottom: 8px; } -.output { - margin: 0 12px 12px; -} - #outputBox { margin: 0; background: #0b1220; @@ -132,6 +226,21 @@ button.secondary { font-size: 12px; } +.debug-box { + margin: 0 12px 12px; + background: #ffffff; + border: 1px solid #d7dee9; + border-radius: 8px; + padding: 8px; +} + +.debug-box > summary { + cursor: pointer; + user-select: none; + color: #334155; + font-size: 13px; +} + @media (max-width: 1600px) { .grid { grid-template-columns: repeat(2, minmax(280px, 1fr)); @@ -142,4 +251,8 @@ button.secondary { .grid { grid-template-columns: 1fr; } + + .modal-grid { + grid-template-columns: 1fr; + } } diff --git a/app/templates/index.html b/app/templates/index.html index f28eb13..cb3abce 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -33,57 +33,81 @@ 3. Selected Episodes - + + + + + - 4. Selected Files - - Root - - Folder - - Refresh Roots + + + - - - Load Folders - Load Files - - - recursive - - - - Discovered Files - - - - Refresh Selected Files - Clear Selected Files - - - Selected Files - - - - Mapping Preview - Filename Preview + - - Output + + Debug Output - + + + + + + File Discovery + Close + + + Root + + Folder + + + + + Load Folders + + + recursive + + Load Files + + + + Folders + + + + Files + + + + + Add Selected Files + + +