Files
bookhoard/web/src/header.ts
T
John O'Keefe fd6f0e3353 fix(web): scan progress — correct percentages, no layout shift, refresh-survivable
Bug: the admin 'Scanning Libraries' widget fed the server's 0-1
progress straight into percent strings and bar widths, so the overall
and per-library percentages capped at '1%' and the bars never filled.
Both now scale by 100 (matching the setup page, which was already
correct).

Bug: scan state only reached clients via websocket events, so a page
opened (or refreshed) mid-scan showed nothing until the next tick —
and the admin widget never re-attached at all. The worker now exposes
active jobs (with their library_id so rows keep their names), served
as GET /api/scanner/active (admin, re-attaches the widget + polling)
and GET /api/scanner/active-summary (any user, restores the sidebar
indicator on load). Idle hides both, exactly as before.

Annoyance: the sidebar's 'Scanning… N%' was a block element between
the logo and the nav, shoving every menu entry down while a scan ran.
The percent now lives inside the fixed-height logo row next to the
spinner (spinner last, so growing digits extend leftward); nothing
below it moves.
2026-10-15 08:29:04 -04:00

129 lines
3.6 KiB
TypeScript

import { Alpine } from "./alpine";
import { initializeSearch } from "./search";
import {
initializeTheme,
changeTheme,
changeWoodPaneling,
loadWoodPaneling,
updateWoodPanelingIndicators,
} from "./theme";
import { getSelectedLibrary } from "./storage";
import { addListener, removeListener } from "./websocket";
const logout = (): void => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/";
};
const restoreLibrarySelection = (): void => {
const savedLibrary = getSelectedLibrary();
if (!savedLibrary) return;
const dropdownSelectors = [
"#library-select",
"#selected-library",
"#library_id",
];
for (const selector of dropdownSelectors) {
const dropdown = document.querySelector(selector) as HTMLSelectElement;
if (dropdown) {
const option = dropdown.querySelector(`option[value="${savedLibrary}"]`);
if (option) {
dropdown.value = savedLibrary;
dropdown.dispatchEvent(new Event("change", { bubbles: true }));
console.log("Restored library selection:", savedLibrary);
return;
}
}
}
};
const initializeScanListener = function (this: any) {
// Restore state on page load: a scan that started before this page
// was opened (or a refresh mid-scan) emits no websocket event until
// its next progress tick, so ask the server directly first.
const restoreActiveScan = async () => {
try {
const token = localStorage.getItem("token");
if (!token) return;
const resp = await fetch("/api/scanner/active-summary", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
if (data.scanning) {
this.scanning = true;
this.scanProgress = Math.round((data.progress || 0) * 100);
}
} catch {
// Offline or endpoint unavailable — websocket events still drive
// the state once they arrive.
}
};
restoreActiveScan();
const handler = (message: any) => {
switch (message.type) {
case "scan_progress":
this.scanning = true;
this.scanProgress = Math.round((message.data.progress || 0) * 100);
this.scanNewItems = message.data.new_items || 0;
this.scanFilesScanned = message.data.files_scanned || 0;
break;
case "scan_complete":
this.scanning = false;
this.scanProgress = 100;
this.scanNewItems = message.data?.new_items || this.scanNewItems;
window.dispatchEvent(
new CustomEvent("bookhoard:scan-complete", {
detail: message.data,
}),
);
setTimeout(() => {
this.scanProgress = 0;
this.scanNewItems = 0;
this.scanFilesScanned = 0;
}, 3000);
break;
case "scan_error":
this.scanning = false;
this.scanProgress = 0;
break;
}
};
addListener(handler);
const headerEl = document.querySelector("[x-data='header']");
if (headerEl) {
const observer = new MutationObserver(() => {
if (!document.contains(headerEl)) {
removeListener(handler);
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
};
export { logout, restoreLibrarySelection };
Alpine.data("header", () => ({
mobileMenuOpen: false,
logout,
initializeSearch,
initializeTheme,
changeTheme,
changeWoodPaneling,
loadWoodPaneling,
updateWoodPanelingIndicators,
restoreLibrarySelection,
initializeScanListener,
scanning: false,
scanProgress: 0,
scanNewItems: 0,
scanFilesScanned: 0,
}));