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:
@@ -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", () => ({}));
|
||||
@@ -21,6 +21,7 @@ import "./events";
|
||||
import "./header";
|
||||
import "./index";
|
||||
import "./library";
|
||||
import "./library-switcher";
|
||||
import "./linking";
|
||||
import "./login";
|
||||
import "./password_validation";
|
||||
|
||||
Vendored
+1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user