From 59cb0e3439cf89b269f58405c69b5ad18b69f4df Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 17 May 2026 21:11:47 -0400 Subject: [PATCH] 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 --- templates/library_switcher.templ | 59 +++++++++++ templates/library_switcher_templ.go | 158 ++++++++++++++++++++++++++++ web/src/library-switcher.ts | 83 +++++++++++++++ web/src/main.ts | 1 + web/src/types/api.d.ts | 1 + 5 files changed, 302 insertions(+) create mode 100644 templates/library_switcher.templ create mode 100644 templates/library_switcher_templ.go create mode 100644 web/src/library-switcher.ts diff --git a/templates/library_switcher.templ b/templates/library_switcher.templ new file mode 100644 index 0000000..224e2bd --- /dev/null +++ b/templates/library_switcher.templ @@ -0,0 +1,59 @@ +package templates + +templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...templ.Component) { +
+
+
+ + +
+ if len(actions) > 0 { +
+ for _, action := range actions { + @action + } +
+ } +
+ +
+} + +templ DashboardActions() { + + +} diff --git a/templates/library_switcher_templ.go b/templates/library_switcher_templ.go new file mode 100644 index 0000000..74c7220 --- /dev/null +++ b/templates/library_switcher_templ.go @@ -0,0 +1,158 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...templ.Component) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(actions) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, action := range actions { + templ_7745c5c3_Err = action.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func DashboardActions() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/web/src/library-switcher.ts b/web/src/library-switcher.ts new file mode 100644 index 0000000..84f9161 --- /dev/null +++ b/web/src/library-switcher.ts @@ -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, +): Promise { + 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; +} + +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", () => ({})); diff --git a/web/src/main.ts b/web/src/main.ts index 404f197..582d6b2 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -21,6 +21,7 @@ import "./events"; import "./header"; import "./index"; import "./library"; +import "./library-switcher"; import "./linking"; import "./login"; import "./password_validation"; diff --git a/web/src/types/api.d.ts b/web/src/types/api.d.ts index 4e1b2ed..bcafa0e 100644 --- a/web/src/types/api.d.ts +++ b/web/src/types/api.d.ts @@ -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)