diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index 1bf3efc..bd36697 100644 --- a/webgui/web/assets/js/app.js +++ b/webgui/web/assets/js/app.js @@ -6,7 +6,7 @@ import { onRoute } from "./state.js"; import { t, currentLocale, setLocale, onLocale } from "./i18n.js"; import { renderRemotes } from "./views/remotes.js"; import { renderBrowse } from "./views/browser.js"; -import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-details-1"; +import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-picker-restore-1"; import { renderConfigureNew, renderConfigureEdit, diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js index a721ffd..dc3e4d5 100644 --- a/webgui/web/assets/js/i18n.js +++ b/webgui/web/assets/js/i18n.js @@ -169,6 +169,11 @@ const STRINGS = { "jobs.dest_remote": "Destination location", "jobs.dest_path": "Destination path", "jobs.path_placeholder": "folder/subfolder or /local/path", + "jobs.pick_folder": "Choose", + "jobs.pick_source_title": "Choose source folder", + "jobs.pick_dest_title": "Choose destination folder", + "jobs.pick_current": "Use current folder", + "jobs.pick_empty": "No folders here.", "jobs.start": "Start Job", "jobs.no_remotes_option": "(no remotes)", "jobs.empty_title": "No jobs yet", @@ -341,6 +346,11 @@ const STRINGS = { "jobs.dest_remote": "目标位置", "jobs.dest_path": "目标路径", "jobs.path_placeholder": "文件夹/子文件夹 或 /local/path", + "jobs.pick_folder": "选择", + "jobs.pick_source_title": "选择源文件夹", + "jobs.pick_dest_title": "选择目标文件夹", + "jobs.pick_current": "使用当前文件夹", + "jobs.pick_empty": "这里没有文件夹。", "jobs.start": "开始任务", "jobs.no_remotes_option": "(无远程存储)", "jobs.empty_title": "尚无任务", diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js index 6636ffe..c2d9163 100644 --- a/webgui/web/assets/js/views/jobs.js +++ b/webgui/web/assets/js/views/jobs.js @@ -19,6 +19,7 @@ let pollTimer = null; const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer"; const LOCAL_FS_VALUE = "__local__"; const expandedJobs = new Set(); +const LOCAL_PICKER_ROOT = "/root"; export async function renderJobs() { const app = document.getElementById("app"); @@ -86,7 +87,10 @@ export async function renderNewJob() {
- +
+ + +
@@ -96,7 +100,10 @@ export async function renderNewJob() {
- +
+ + +
${t("jobs.local_path_help")} @@ -114,6 +121,21 @@ export async function renderNewJob() { } const form = document.getElementById("new-job-form"); + form.querySelector('[data-pick="src"]').addEventListener("click", () => { + openFolderPicker({ + location: form.elements.srcRemote.value, + pathInput: form.elements.srcPath, + title: t("jobs.pick_source_title"), + }); + }); + form.querySelector('[data-pick="dst"]').addEventListener("click", () => { + openFolderPicker({ + location: form.elements.dstRemote.value, + pathInput: form.elements.dstPath, + title: t("jobs.pick_dest_title"), + }); + }); + form.addEventListener("submit", async (e) => { e.preventDefault(); const action = form.elements.action.value; @@ -159,6 +181,124 @@ function buildJobFs(location, rawPath) { return remotePath ? `${location}${remotePath}` : location; } +async function openFolderPicker({ location, pathInput, title }) { + const root = document.getElementById("modal-root"); + let current = initialPickerPath(location, pathInput.value); + + const close = () => { + root.innerHTML = ""; + }; + + const choose = () => { + pathInput.value = current; + close(); + }; + + const render = async () => { + root.innerHTML = ` + + `; + + const overlay = root.querySelector(".modal-overlay"); + const list = root.querySelector(".folder-picker-list"); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) close(); + }); + root.querySelector("[data-cancel]").addEventListener("click", close); + root.querySelector("[data-select]").addEventListener("click", choose); + + try { + const dirs = await listDirs(location, current); + const parent = parentPickerPath(location, current); + list.innerHTML = ` + + ${dirs.length === 0 ? `

${t("jobs.pick_empty")}

` : dirs.map((dir) => ` + + `).join("")} + `; + list.querySelectorAll("[data-path]").forEach((btn) => { + btn.addEventListener("click", () => { + if (btn.disabled) return; + current = btn.dataset.path; + render(); + }); + }); + } catch (e) { + list.innerHTML = `

${t("error.couldnt_list")}

${escapeHtml(e.message)}

`; + } + }; + + await render(); +} + +async function listDirs(location, current) { + const isLocal = location === LOCAL_FS_VALUE; + const body = isLocal + ? { fs: current || LOCAL_PICKER_ROOT, remote: "" } + : { fs: location, remote: trimRemotePath(current) }; + const res = await post("operations/list", { + ...body, + opt: { dirsOnly: true, noModTime: true, noMimeType: true, showHash: false }, + }); + const items = (res && res.list) || []; + return items + .filter((item) => item.IsDir) + .sort((a, b) => a.Name.localeCompare(b.Name)); +} + +function initialPickerPath(location, rawPath) { + const value = rawPath.trim(); + if (location === LOCAL_FS_VALUE) { + return value || LOCAL_PICKER_ROOT; + } + return trimRemotePath(value); +} + +function displayPickerPath(location, current) { + if (location === LOCAL_FS_VALUE) { + return current || "/"; + } + return current ? `${location}${current}` : location; +} + +function parentPickerPath(location, current) { + if (location === LOCAL_FS_VALUE) { + if (!current || current === "/") return null; + const normalized = current.replace(/\/+$/g, ""); + const parent = normalized.slice(0, normalized.lastIndexOf("/")) || "/"; + return parent; + } + if (!current) return null; + const parts = trimRemotePath(current).split("/").filter(Boolean); + parts.pop(); + return parts.join("/"); +} + +function joinPickerPath(current, name) { + if (!current || current === "/") return current === "/" ? `/${name}` : name; + return `${current.replace(/\/+$/g, "")}/${name}`; +} + +function trimRemotePath(path) { + return path.trim().replace(/^\/+|\/+$/g, ""); +} + async function refreshJobs() { const card = document.getElementById("jobs-card"); if (!card) return false; @@ -431,6 +571,10 @@ function escapeHtml(s) { .replace(/'/g, "'"); } +function escapeAttr(s) { + return escapeHtml(s); +} + function formatDateTime(value) { if (!value) return ""; const date = new Date(value); diff --git a/webgui/web/assets/styles/components.css b/webgui/web/assets/styles/components.css index abd23f9..ae174de 100644 --- a/webgui/web/assets/styles/components.css +++ b/webgui/web/assets/styles/components.css @@ -303,6 +303,21 @@ color: var(--color-muted); } +.input-action { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.input-action .input { + flex: 1; + min-width: 0; +} + +.input-action .btn { + flex-shrink: 0; +} + /* ---------- Badges ---------- */ .badge { display: inline-flex; @@ -580,6 +595,49 @@ border-color: transparent; } +.folder-picker { + max-width: 640px; +} + +.folder-picker-path { + padding: var(--space-sm) var(--space-md); + background-color: var(--color-surface-dark-elevated); + border-radius: var(--radius-md); + color: var(--color-on-dark-soft); + font: var(--typo-code); + word-break: break-all; +} + +.folder-picker-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + max-height: 360px; + overflow: auto; +} + +.folder-picker-row { + width: 100%; + padding: var(--space-sm) var(--space-md); + background-color: var(--color-surface-dark-elevated); + color: var(--color-on-dark); + border: 1px solid transparent; + border-radius: var(--radius-md); + text-align: left; + font: var(--typo-body-sm); + cursor: pointer; +} + +.folder-picker-row:hover { + border-color: var(--color-primary); +} + +.folder-picker-row:disabled { + color: var(--color-muted-soft); + cursor: not-allowed; + opacity: 0.6; +} + /* ---------- Footer ---------- */ .footer { background-color: var(--color-surface-dark); diff --git a/webgui/web/index.html b/webgui/web/index.html index 22c053b..7899c9f 100644 --- a/webgui/web/index.html +++ b/webgui/web/index.html @@ -7,7 +7,7 @@ - +
@@ -80,6 +80,6 @@
- +