feat(library-switcher): add shared library switcher component and module

Add reusable library switcher infrastructure that can be used across
dashboard, collections list, and collection detail pages.

New files:
- templates/library_switcher.templ: Shared LibrarySwitcher component
  with variadic actions slot for page-specific buttons (e.g. dashboard
  settings/refresh). Includes DashboardActions sub-component.
- web/src/library-switcher.ts: Shared module providing:
  - initLibrarySwitcher(): syncs dropdown with localStorage, attaches
    change listener with configurable onSwitch callback
  - switchWithTransition(): generic fade-out -> spinner -> fetch ->
    fade-in transition used by all pages
  - getCurrentLibraryId(): reads "selectedLibrary" from localStorage

Modified:
- web/src/main.ts: import new library-switcher module
- web/src/types/api.d.ts: add book_count field to CollectionData
This commit is contained in:
2026-05-17 21:11:47 -04:00
parent 158fcb510b
commit 59cb0e3439
5 changed files with 302 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
const STORAGE_KEY = "selectedLibrary";
export function getCurrentLibraryId(): string {
return localStorage.getItem(STORAGE_KEY) || "";
}
export async function switchWithTransition(
containerId: string,
fetchFn: () => Promise<void>,
): Promise<void> {
const container = document.getElementById(containerId);
const loading = document.getElementById("loading-spinner");
if (!container || !loading) return;
try {
container.classList.add(
"opacity-0",
"transition-opacity",
"duration-150",
);
await new Promise((resolve) => setTimeout(resolve, 150));
loading.classList.remove("hidden");
await fetchFn();
container.classList.remove("duration-150");
container.classList.add("duration-300");
void container.offsetHeight;
container.classList.remove("opacity-0");
setTimeout(() => {
container.classList.remove("transition-opacity", "duration-300");
}, 300);
} catch (error) {
container.classList.remove(
"opacity-0",
"transition-opacity",
"duration-150",
"duration-300",
);
showToast("Failed to load library", "error");
console.error("Switch library error:", error);
} finally {
loading.classList.add("hidden");
}
}
export interface LibrarySwitcherOptions {
onSwitch: (libraryId: string) => Promise<void>;
}
export function initLibrarySwitcher(options: LibrarySwitcherOptions): void {
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
if (!librarySelect) return;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const option = librarySelect.querySelector(
`option[value="${stored}"]`,
);
if (option) {
librarySelect.value = stored;
}
}
librarySelect.addEventListener("change", async (e) => {
const target = e.target as HTMLSelectElement;
if (!target.value && target.value !== "") return;
const libraryId = target.value;
localStorage.setItem(STORAGE_KEY, libraryId);
await options.onSwitch(libraryId);
});
}
Alpine.data("librarySwitcher", () => ({}));
+1
View File
@@ -21,6 +21,7 @@ import "./events";
import "./header";
import "./index";
import "./library";
import "./library-switcher";
import "./linking";
import "./login";
import "./password_validation";
+1
View File
@@ -90,6 +90,7 @@ interface CollectionData {
icon: string;
auto_assign_rules?: unknown;
created_at: string;
book_count?: number;
}
// Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71)