add(ui/ux): per-entry progress and status while importing

This commit is contained in:
Steppenstreuner
2026-08-29 07:52:03 +02:00
parent d432881a7a
commit e20db4a9f1
4 changed files with 96 additions and 21 deletions
+80 -20
View File
@@ -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();
+4
View File
@@ -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>
+11
View File
@@ -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) */