add(ui/ux): per-entry progress and status while importing
This commit is contained in:
+80
-20
@@ -745,7 +745,7 @@ function renderImportTable() {
|
||||
.filter((r) => r.media_type === item.media_type && r.imported_path)
|
||||
.map((r) => option(r, " ↩︎"))
|
||||
.join("");
|
||||
return `<tr>
|
||||
return `<tr class="${item.importState ? "row-" + item.importState : ""}">
|
||||
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
||||
<td class="mono">
|
||||
${esc(item.name)}${item.is_dir ? " 📁" : ""}
|
||||
@@ -765,12 +765,14 @@ function renderImportTable() {
|
||||
? `<button class="secondary" data-split="${i}" title="${item.parts ? "Zusammenfassung wieder auflösen" : "Ordner in einzelne Titel aufteilen"}">✂️</button>`
|
||||
: ""}
|
||||
</td>
|
||||
${scoreCell(item)}
|
||||
${item.importState ? importStateCell(item) : scoreCell(item)}
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
table.hidden = false;
|
||||
$("#import-btn").hidden = false;
|
||||
const showsState = scanItems.some((it) => it.importState);
|
||||
table.querySelector("thead th:last-child").textContent = showsState ? "Status" : "Score";
|
||||
|
||||
tbody.querySelectorAll("[data-quick]").forEach((b) =>
|
||||
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
|
||||
@@ -801,6 +803,14 @@ function renderImportTable() {
|
||||
updateImportPageInfo(pages);
|
||||
}
|
||||
|
||||
function importStateCell(item) {
|
||||
if (item.importState === "running") {
|
||||
return '<td class="state running"><span class="spinner"></span> läuft…</td>';
|
||||
}
|
||||
if (item.importState === "done") return '<td class="state done">✓ importiert</td>';
|
||||
return `<td class="state failed" title="${esc(item.importError)}">✗ Fehler</td>`;
|
||||
}
|
||||
|
||||
function updateImportPageInfo(pages) {
|
||||
pages = pages ?? Math.max(1, Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE));
|
||||
const selected = scanItems.filter((it) => it.checked && it.request_id).length;
|
||||
@@ -1130,7 +1140,7 @@ const IMPORT_BATCH_SIZE = 20;
|
||||
|
||||
// several entries on one request means one audiobook split across folders:
|
||||
// importing them one by one only imports the first and fails the rest
|
||||
function collapseSharedRequests(chosen) {
|
||||
function collapseSharedRequests(chosen, opts = {}) {
|
||||
const byRequest = new Map();
|
||||
for (const item of chosen) {
|
||||
const group = byRequest.get(item.request_id);
|
||||
@@ -1149,13 +1159,17 @@ function collapseSharedRequests(chosen) {
|
||||
);
|
||||
if (!ok) return null;
|
||||
}
|
||||
return [...byRequest.values()].map((group) => ({
|
||||
path: group.length > 1 ? sharedParent(group) : group[0].path,
|
||||
is_dir: group.length > 1 ? true : group[0].is_dir,
|
||||
files: group.flatMap((it) => it.files),
|
||||
request_id: group[0].request_id,
|
||||
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
|
||||
const built = [...byRequest.values()].map((group) => ({
|
||||
parts: group,
|
||||
item: {
|
||||
path: group.length > 1 ? sharedParent(group) : group[0].path,
|
||||
is_dir: group.length > 1 ? true : group[0].is_dir,
|
||||
files: group.flatMap((it) => it.files),
|
||||
request_id: group[0].request_id,
|
||||
append: importedReqs.some((r) => r.id === group[0].request_id), // orphans re-import
|
||||
},
|
||||
}));
|
||||
return opts.keepGroups ? built : built.map((b) => b.item);
|
||||
}
|
||||
|
||||
// deepest folder that holds all of the group's entries
|
||||
@@ -1170,19 +1184,47 @@ function sharedParent(group) {
|
||||
$("#import-btn").addEventListener("click", async () => {
|
||||
const chosen = scanItems.filter((item) => item.checked && item.request_id);
|
||||
if (!chosen.length) { toast("Nichts ausgewählt", true); return; }
|
||||
const items = collapseSharedRequests(chosen);
|
||||
if (!items) return;
|
||||
const groups = collapseSharedRequests(chosen, { keepGroups: true });
|
||||
if (!groups) return;
|
||||
|
||||
const btn = $("#import-btn");
|
||||
btn.disabled = true;
|
||||
scanItems.forEach((it) => { it.importState = null; it.importError = ""; });
|
||||
const total = groups.length;
|
||||
const allResults = [];
|
||||
let done = 0;
|
||||
setImportProgress(0, total);
|
||||
|
||||
try {
|
||||
for (let done = 0; done < items.length; done += IMPORT_BATCH_SIZE) {
|
||||
btn.innerHTML =
|
||||
`<span class="spinner"></span> Importiere… ${Math.min(done + IMPORT_BATCH_SIZE, items.length)}/${items.length}`;
|
||||
const batch = items.slice(done, done + IMPORT_BATCH_SIZE);
|
||||
const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items: batch }) });
|
||||
allResults.push(...res.results);
|
||||
// one request per entry: the row can only turn green once its own move came
|
||||
// back, and a batch would only report after all of its items finished
|
||||
for (const { item, parts } of groups) {
|
||||
parts.forEach((p) => (p.importState = "running"));
|
||||
// follow the running entry across pages, otherwise only the bar moves
|
||||
applyImportView();
|
||||
const pos = viewIdx.indexOf(scanItems.indexOf(parts[0]));
|
||||
if (pos >= 0) importPage = Math.floor(pos / IMPORT_PAGE_SIZE);
|
||||
renderImportTable();
|
||||
let result;
|
||||
try {
|
||||
const res = await api("/api/import", {
|
||||
method: "POST", body: JSON.stringify({ items: [item] }),
|
||||
});
|
||||
result = res.results[0];
|
||||
} catch (err) {
|
||||
result = { path: item.path, ok: false, error: err.message };
|
||||
}
|
||||
allResults.push(result);
|
||||
parts.forEach((p) => {
|
||||
p.importState = result.ok ? "done" : "failed";
|
||||
p.importError = result.ok ? "" : result.error;
|
||||
p.checked = false;
|
||||
});
|
||||
done += 1;
|
||||
setImportProgress(done, total);
|
||||
renderImportTable();
|
||||
}
|
||||
|
||||
const failed = allResults.filter((r) => !r.ok);
|
||||
$("#import-summary").innerHTML =
|
||||
`<p class="${failed.length ? "error" : "ok"}"><strong>` +
|
||||
@@ -1198,14 +1240,32 @@ $("#import-btn").addEventListener("click", async () => {
|
||||
.join("");
|
||||
toast(failed.length ? `Import fertig, ${failed.length} Fehler` : "Import fertig", failed.length > 0);
|
||||
refreshMissingBadge();
|
||||
$("#scan-btn").click();
|
||||
} catch (err) { toast(err.message, true); }
|
||||
finally {
|
||||
// the list stays as it is, with its green and red rows - rescanning would
|
||||
// wipe exactly the feedback the user just waited for
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Ausgewählte importieren";
|
||||
}
|
||||
});
|
||||
|
||||
function setImportProgress(done, total) {
|
||||
const box = $("#import-progress");
|
||||
if (!total || done >= total) {
|
||||
box.hidden = done < total;
|
||||
if (done >= total) {
|
||||
box.querySelector("progress").value = total;
|
||||
box.querySelector("span").textContent = `${total}/${total} · fertig`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
box.hidden = false;
|
||||
const bar = box.querySelector("progress");
|
||||
bar.max = total;
|
||||
bar.value = done;
|
||||
box.querySelector("span").textContent =
|
||||
`${done}/${total} · ${Math.round((done / total) * 100)} %`;
|
||||
}
|
||||
|
||||
// ---- init ----
|
||||
loadLibraries();
|
||||
refreshMissingBadge();
|
||||
|
||||
@@ -192,6 +192,10 @@
|
||||
</div>
|
||||
<div class="bar">
|
||||
<button id="import-btn" hidden>Ausgewählte importieren</button>
|
||||
<div id="import-progress" hidden>
|
||||
<progress max="1" value="0"></progress>
|
||||
<span class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="import-summary"></div>
|
||||
<div id="import-results"></div>
|
||||
|
||||
@@ -55,6 +55,17 @@ button:disabled { opacity: 0.6; cursor: default; }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
|
||||
.subpath { color: var(--muted); font-size: 0.75rem; font-family: ui-monospace, monospace; }
|
||||
.tag.warn { background: #8a6d1f; }
|
||||
|
||||
/* per-entry import feedback */
|
||||
#import-progress { display: flex; align-items: center; gap: 0.6rem; flex: 1; }
|
||||
#import-progress progress { flex: 1; max-width: 26rem; height: 0.7rem; }
|
||||
tr.row-running { background: rgba(90, 140, 220, 0.12); }
|
||||
tr.row-done { background: rgba(80, 170, 110, 0.12); }
|
||||
tr.row-failed { background: rgba(200, 90, 90, 0.14); }
|
||||
td.state { white-space: nowrap; font-size: 0.85rem; }
|
||||
td.state.done { color: var(--ok); }
|
||||
td.state.failed { color: #e08585; }
|
||||
td.state.running { color: var(--muted); }
|
||||
.tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; }
|
||||
|
||||
/* view modes (Missing/Importiert) */
|
||||
|
||||
Reference in New Issue
Block a user