From 55a299540cd627af935a05b738e635aced5daebb Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 16 May 2026 19:31:23 -0400 Subject: [PATCH] feat(header): add scan progress spinner and dispatch scan-complete event Add a scan progress indicator to the header that shows during library scans: - Spinning SVG icon next to the BookHoard title - Percentage display during active scans - Dispatches bookhoard:scan-complete custom DOM event on window when scan finishes, enabling other components (dashboard) to react without polling - Auto-resets progress display after 3 seconds - Uses WebSocket pub/sub via addListener/removeListener with cleanup on header element removal --- web/src/header.ts | 62 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/web/src/header.ts b/web/src/header.ts index 82c8556..b1d02ef 100644 --- a/web/src/header.ts +++ b/web/src/header.ts @@ -1,5 +1,3 @@ -// Header functionality - import { Alpine } from "./alpine"; import { initializeSearch } from "./search"; import { @@ -10,6 +8,7 @@ import { updateWoodPanelingIndicators, } from "./theme"; import { getSelectedLibrary } from "./storage"; +import { addListener, removeListener } from "./websocket"; const logout = (): void => { localStorage.removeItem("token"); @@ -17,16 +16,14 @@ const logout = (): void => { window.location.href = "/"; }; -// Looks for saved library in localStorage and selects it in current page's dropdown const restoreLibrarySelection = (): void => { const savedLibrary = getSelectedLibrary(); if (!savedLibrary) return; - // Common library dropdown IDs across pages const dropdownSelectors = [ - "#library-select", // dashboard, bookshelf - "#selected-library", // alternate naming - "#library_id", // form field + "#library-select", + "#selected-library", + "#library_id", ]; for (const selector of dropdownSelectors) { @@ -35,7 +32,6 @@ const restoreLibrarySelection = (): void => { const option = dropdown.querySelector(`option[value="${savedLibrary}"]`); if (option) { dropdown.value = savedLibrary; - // Trigger any htmx/change handlers dropdown.dispatchEvent(new Event("change", { bubbles: true })); console.log("Restored library selection:", savedLibrary); return; @@ -44,6 +40,51 @@ const restoreLibrarySelection = (): void => { } }; +const initializeScanListener = function (this: any) { + 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", () => ({ @@ -55,4 +96,9 @@ Alpine.data("header", () => ({ loadWoodPaneling, updateWoodPanelingIndicators, restoreLibrarySelection, + initializeScanListener, + scanning: false, + scanProgress: 0, + scanNewItems: 0, + scanFilesScanned: 0, }));