From 5782a4e314d166fe334a96adc4dc57e33fbb8060 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 16 Mar 2026 16:24:03 -0400 Subject: [PATCH] feat(bookshelf): add filter bar with HTMX integration and filter persistence - Add bookshelf route with library selection from query param or first available - Add filter bar UI with library selector, search, and filter controls - Integrate HTMX for dynamic filtering (hx-get to /api/media-items/filtered) - Add Alpine.js component for filter state management - Add filter save/load functionality via /api/bookshelf/filters endpoint - Update bookshelf.ts to use Alpine.js for reactive state instead of DOM manipulation --- internal/router/frontend.go | 52 +++++++ templates/bookshelf.templ | 287 +++++++++++++++++++++++++++++++---- templates/bookshelf_templ.go | 122 +++++++++++---- web/src/bookshelf.ts | 274 +++++++++++---------------------- 4 files changed, 489 insertions(+), 246 deletions(-) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index adb3acd..1e8c979 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -203,6 +203,58 @@ func registerFrontendRoutes(cfg *Config) { return c.HTML(http.StatusOK, buf.String()) }) + // Bookshelf Page + frontendProtected.GET("/bookshelf", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + + var errorMsg string + + // Get library_id from query param or user's first library + libraryID := c.QueryParam("library_id") + if libraryID == "" { + userUUID, _ := uuid.Parse(user.ID) + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) + if err == nil && len(libraries) > 0 { + libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16]) + libraryID = libUUID.String() + } else { + errorMsg = "No libraries available" + } + } + + // Get libraries for dropdown + userUUID, _ := uuid.Parse(user.ID) + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) + if err != nil { + log.Printf("GetUserVisibleLibraries failed: %v", err) + libraries = []database.GetUserVisibleLibrariesRow{} + if errorMsg == "" { + errorMsg = "Error loading libraries" + } + } + + libData := make([]templates.LibraryData, len(libraries)) + for i, lib := range libraries { + libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) + libData[i] = templates.LibraryData{ + ID: libUUID.String(), + Name: lib.Name, + Description: getText(lib.Description), + TypeName: lib.TypeName, + } + } + + var buf bytes.Buffer + err = templates.BookShelf(user, libData, libraryID, errorMsg).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + // Collections page frontendProtected.GET("/collections", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index 4e858d4..5a1b6e2 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -1,6 +1,6 @@ package templates -templ BookShelf(user User, libraries []LibraryData) { +templ BookShelf(user User, libraries []LibraryData, currentLibraryID string, errorMessage string) { @@ -8,52 +8,283 @@ templ BookShelf(user User, libraries []LibraryData) { Library - Bookhoard + - + @Header(user, "/bookshelf") -
- -
-
-
- - if len(libraries) == 0 { } else { for _, lib := range libraries { - + if lib.ID == currentLibraryID { + + } else { + + } } }
-
- +
+ +
+
+ +
- -
- -
-
-

Loading your library...

+ +
+ +
+ + + + if errorMessage != "" { +
+

{ errorMessage }

- - - - + + + + } diff --git a/templates/bookshelf_templ.go b/templates/bookshelf_templ.go index 88eabdf..e741411 100644 --- a/templates/bookshelf_templ.go +++ b/templates/bookshelf_templ.go @@ -8,7 +8,7 @@ package templates import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -func BookShelf(user User, libraries []LibraryData) templ.Component { +func BookShelf(user User, libraries []LibraryData, currentLibraryID string, errorMessage string) 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 { @@ -29,7 +29,7 @@ func BookShelf(user User, libraries []LibraryData) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Library - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Library - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -37,7 +37,7 @@ func BookShelf(user User, libraries []LibraryData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -48,39 +48,95 @@ func BookShelf(user User, libraries []LibraryData) templ.Component { } } else { for _, lib := range libraries { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if lib.ID == currentLibraryID { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Loading your library...

📚

No Books Yet

Select a library to view your collection

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errorMessage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 227, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Save Filter

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/bookshelf.ts b/web/src/bookshelf.ts index cd73744..1aa6044 100644 --- a/web/src/bookshelf.ts +++ b/web/src/bookshelf.ts @@ -1,200 +1,104 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; - -let currentLibraryId = ""; -let mediaItems: unknown[] = []; - -function initBookshelf(): void { - const savedLibrary = localStorage.getItem("selectedLibrary"); - if (savedLibrary) { - const select = document.getElementById( - "library-select", - ) as HTMLSelectElement; - if (select && select.value) { - currentLibraryId = savedLibrary; - loadBookshelf(savedLibrary); - } - } -} - -function selectLibrary(): void { - const select = document.getElementById("library-select") as HTMLSelectElement; - if (!select) return; - - const libraryId = select.value; - if (!libraryId) return; - - currentLibraryId = libraryId; - localStorage.setItem("selectedLibrary", libraryId); - loadBookshelf(libraryId); -} - -async function loadBookshelf(libraryId: string): Promise { - const token = localStorage.getItem("token"); - if (!token || !libraryId) return; - - this.isLoading = true; - - try { - const response = await fetch( - `/api/media-items?library_id=${libraryId}&limit=100&offset=0`, - { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }, - ); - - if (response.ok) { - const data = await response.json(); - mediaItems = data; - renderBookshelf(); - } - } catch (error) { - console.error("Error loading bookshelf:", error); - showToast("Error loading books", "error"); - this.isLoading = false; - } -} - -function showEmptyState(): void { - const booksGrid = document.getElementById("books-grid"); - const emptyState = document.getElementById("empty-state"); - const loading = document.getElementById("loading"); - - if (loading) loading.style.display = "none"; - if (booksGrid) booksGrid.classList.add("hidden"); - if (emptyState) emptyState.classList.remove("hidden"); -} - -function renderBookshelf(): void { - if (!mediaItems || mediaItems.length === 0) { - this.isLoading = false; - this.hasBooks = false; - return; - } - - const booksGrid = document.getElementById("books-grid"); - if (!booksGrid) return; - - const booksPerShelf = 6; - const shelves: unknown[][] = []; - - for (let i = 0; i < mediaItems.length; i += booksPerShelf) { - shelves.push(mediaItems.slice(i, i + booksPerShelf)); - } - - let html = ""; - shelves.forEach((shelfBooks) => { - html += `
-
- ${shelfBooks.map((book: any) => renderBookCard(book)).join("")} -
-
-
`; - }); - - booksGrid.innerHTML = html; -} - -function renderBookCard(book: any): string { - const coverUrl = book.cover_image_path || "/static/placeholder-book.svg"; - const authorHtml = book.author - ? `

${book.author}

` - : ""; - return `
-
-
- ${book.title} -
-
-

${book.title}

- ${authorHtml} -
-
`; -} - -function viewBook(_bookId: string): void { - showToast("Book viewer coming soon!", "info"); -} - -function selectBook(bookId: string): void { - localStorage.setItem("selectedBook", bookId); - window.location.href = `/books/${bookId}`; -} - -function changePage(page: number): void { - if (!currentLibraryId) return; - const offset = (page - 1) * 50; - loadBookshelfPaginated(currentLibraryId, offset); -} - -async function loadBookshelfPaginated( - libraryId: string, - offset: number, -): Promise { +async function loadSavedFilters(): Promise { const token = localStorage.getItem("token"); if (!token) return; - try { - const response = await fetch( - `/api/media-items?library_id=${libraryId}&limit=50&offset=${offset}`, - { headers: { Authorization: `Bearer ${token}` } }, - ); - + const response = await fetch("/api/bookshelf/filters", { + headers: { Authorization: `Bearer ${token}` }, + }); if (response.ok) { - const data = await response.json(); - mediaItems = data; - renderBookshelf(); + const filters = await response.json(); + localStorage.setItem("bookshelfFilters", JSON.stringify(filters)); } } catch (error) { - console.error("Failed to load bookshelf:", error); + console.error("Failed to load saved filters:", error); } } - -function setupEventDelegation(): void { - const container = document.getElementById("books-grid"); - if (!container) return; - - container.addEventListener("click", (e) => { - const target = e.target as HTMLElement; - const card = target.closest("[data-book-id]") as HTMLElement; - - if (card) { - const bookId = card.dataset.bookId; - if (bookId) { - selectBook(bookId); - } +// Note: saveFilter and showSaveFilterModal are now methods on the Alpine component +// They access state via 'this' instead of window.Alpine +function clearFilters(): void { + const filterForm = document.getElementById("filter-form") as HTMLFormElement; + if (!filterForm) return; + const inputs = filterForm.querySelectorAll("input, select"); + inputs.forEach((input) => { + if (input instanceof HTMLInputElement && input.type === "checkbox") { + input.checked = false; + } else { + (input as HTMLInputElement).value = ""; } }); + htmx.trigger(filterForm, "change"); } - -export { - changePage, - initBookshelf, - loadBookshelf, - selectBook, - selectLibrary, - setupEventDelegation, - viewBook, -}; - +// Alpine.js component - all state managed locally, no window.Alpine at runtime Alpine.data("bookshelf", () => ({ - // State Variables - isLoading: true, - hasBooks: false, - - // Methods - changePage, - initBookshelf, - loadBookshelf, - selectBook, - selectLibrary, - setupEventDelegation, - viewBook, + showSaveModal: false, + filterName: "", + initBookshelf(): void { + loadSavedFilters(); + const librarySelect = document.getElementById( + "library-select", + ) as HTMLSelectElement; + if (librarySelect && librarySelect.value) { + const filterForm = document.getElementById( + "filter-form", + ) as HTMLFormElement; + if (filterForm) { + htmx.trigger(librarySelect, "change"); + } + } + }, + clearFilters, + showSaveFilterModal(): void { + this.showSaveModal = true; + this.filterName = ""; + }, + hideSaveFilterModal(): void { + this.showSaveModal = false; + this.filterName = ""; + }, + async saveFilter(event: Event): Promise { + event.preventDefault(); + const token = localStorage.getItem("token"); + if (!token) { + showToast("Not authenticated", "error"); + return; + } + if (!this.filterName) { + showToast("Please enter a filter name", "error"); + return; + } + const filterForm = document.getElementById( + "filter-form", + ) as HTMLFormElement; + const formData = new FormData(filterForm); + const filterData: Record = {}; + formData.forEach((value, key) => { + filterData[key] = value.toString(); + }); + try { + const response = await fetch("/api/bookshelf/filters", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + name: this.filterName, + filters: filterData, + }), + }); + if (response.ok) { + showToast("Filter saved successfully", "success"); + this.hideSaveFilterModal(); + loadSavedFilters(); + } else { + showToast("Failed to save filter", "error"); + } + } catch (error) { + console.error("Failed to save filter:", error); + showToast("Error saving filter", "error"); + } + }, })); +export { clearFilters };