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,59 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...templ.Component) {
|
||||||
|
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||||
|
<div class="w-full px-4 py-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
|
||||||
|
<select
|
||||||
|
id="library-select"
|
||||||
|
name="library_id"
|
||||||
|
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||||
|
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
<option value="">All Libraries</option>
|
||||||
|
for _, lib := range libData {
|
||||||
|
if lib.ID == currentLibraryID {
|
||||||
|
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||||||
|
} else {
|
||||||
|
<option value={ lib.ID }>{ lib.Name }</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
if len(actions) > 0 {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
for _, action := range actions {
|
||||||
|
@action
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="loading-spinner"
|
||||||
|
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
|
||||||
|
style="background-color: var(--bg-primary);"
|
||||||
|
>
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ DashboardActions() {
|
||||||
|
<button
|
||||||
|
data-action="open-dashboard-settings"
|
||||||
|
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||||
|
style="background-color: var(--bg-secondary);"
|
||||||
|
title="Customize Dashboard"
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-action="reload-page"
|
||||||
|
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||||
|
style="background-color: var(--bg-secondary);"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
🔄
|
||||||
|
</button>
|
||||||
|
}
|
||||||
@@ -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, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries</option> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, lib := range libData {
|
||||||
|
if lib.ID == currentLibraryID {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 29}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" selected>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 51}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 29}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 42}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(actions) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex items-center gap-2\">")
|
||||||
|
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, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
|
||||||
|
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, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -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 "./header";
|
||||||
import "./index";
|
import "./index";
|
||||||
import "./library";
|
import "./library";
|
||||||
|
import "./library-switcher";
|
||||||
import "./linking";
|
import "./linking";
|
||||||
import "./login";
|
import "./login";
|
||||||
import "./password_validation";
|
import "./password_validation";
|
||||||
|
|||||||
Vendored
+1
@@ -90,6 +90,7 @@ interface CollectionData {
|
|||||||
icon: string;
|
icon: string;
|
||||||
auto_assign_rules?: unknown;
|
auto_assign_rules?: unknown;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
book_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71)
|
// Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71)
|
||||||
|
|||||||
Reference in New Issue
Block a user