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
This commit is contained in:
@@ -203,6 +203,58 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
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
|
// Collections page
|
||||||
frontendProtected.GET("/collections", func(c *echo.Context) error {
|
frontendProtected.GET("/collections", func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
|||||||
+259
-28
@@ -1,6 +1,6 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ BookShelf(user User, libraries []LibraryData) {
|
templ BookShelf(user User, libraries []LibraryData, currentLibraryID string, errorMessage string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -8,52 +8,283 @@ templ BookShelf(user User, libraries []LibraryData) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Library - Bookhoard</title>
|
<title>Library - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }" x-data="bookshelf" x-init="initBookshelf(); setupEventDelegation()">
|
<body
|
||||||
|
class="theme-{ user.Theme }"
|
||||||
|
x-data="bookshelf"
|
||||||
|
x-init="initBookshelf()"
|
||||||
|
>
|
||||||
@Header(user, "/bookshelf")
|
@Header(user, "/bookshelf")
|
||||||
<div class="w-full px-4 sm:px-6 lg:8 py-8">
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Library Selector -->
|
<!-- Filter Bar -->
|
||||||
<div class="mb-8">
|
<div
|
||||||
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
class="mb-6 card p-4 rounded-lg border"
|
||||||
<div class="flex-1 w-full">
|
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Select Library</label>
|
>
|
||||||
<select id="library-select" @change="selectLibrary" class="w-full px-4 py-3 border rounded-lg text-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
|
<!-- Library Selector -->
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Library
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="library-select"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
hx-get="/api/media-items/filtered"
|
||||||
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#filter-form"
|
||||||
|
>
|
||||||
if len(libraries) == 0 {
|
if len(libraries) == 0 {
|
||||||
<option value="">No libraries available</option>
|
<option value="">No libraries available</option>
|
||||||
} else {
|
} else {
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
<option value={ lib.ID }>{ lib.Name }</option>
|
if lib.ID == currentLibraryID {
|
||||||
|
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||||||
|
} else {
|
||||||
|
<option value={ lib.ID }>{ lib.Name }</option>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<!-- Search Input -->
|
||||||
<button @click="loadBookshelf" class="btn-primary px-6 py-3 rounded-lg font-medium">
|
<div class="flex-1 min-w-[200px]">
|
||||||
Refresh
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
placeholder="Search title, author..."
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
hx-get="/api/media-items/filtered"
|
||||||
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-include="#filter-form"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Author Filter -->
|
||||||
|
<div class="flex-1 min-w-[150px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Author
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="author_filter"
|
||||||
|
placeholder="Filter by author"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
hx-get="/api/media-items/filtered"
|
||||||
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#filter-form"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Series Filter -->
|
||||||
|
<div class="flex-1 min-w-[150px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Series
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="series_filter"
|
||||||
|
placeholder="Filter by series"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
hx-get="/api/media-items/filtered"
|
||||||
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#filter-form"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Genre Filter -->
|
||||||
|
<div class="flex-1 min-w-[150px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Genre
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="genre_filter"
|
||||||
|
placeholder="Filter by genre"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
hx-get="/api/media-items/filtered"
|
||||||
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#filter-form"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Year Range -->
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Year Range
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="year_min"
|
||||||
|
placeholder="From"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
hx-get="/api/media-items/filtered"
|
||||||
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#filter-form"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="year_max"
|
||||||
|
placeholder="To"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
hx-get="/api/media-items/filtered"
|
||||||
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#filter-form"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Has Cover Filter -->
|
||||||
|
<div class="flex items-end">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="has_cover"
|
||||||
|
value="true"
|
||||||
|
class="w-4 h-4 rounded"
|
||||||
|
hx-get="/api/media-items/filtered"
|
||||||
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#filter-form"
|
||||||
|
/>
|
||||||
|
<span class="text-sm" style="color: var(--text-primary)">Has Cover</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<!-- Sort -->
|
||||||
|
<div class="flex-1 min-w-[150px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Sort By
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="sort"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
hx-get="/api/media-items/filtered"
|
||||||
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#filter-form"
|
||||||
|
>
|
||||||
|
<option value="title ASC">Title (A-Z)</option>
|
||||||
|
<option value="title DESC">Title (Z-A)</option>
|
||||||
|
<option value="author ASC">Author (A-Z)</option>
|
||||||
|
<option value="created_at DESC">Date Added</option>
|
||||||
|
<option value="page_count DESC">Page Count</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Save Filter Button -->
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button
|
||||||
|
@click="showSaveFilterModal()"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||||
|
>
|
||||||
|
💾 Save Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Clear Filters -->
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button
|
||||||
|
@click="clearFilters()"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium border"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
✕ Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Hidden form for HTMX include -->
|
||||||
|
<form id="filter-form" class="hidden">
|
||||||
|
<input type="hidden" name="limit" value="50"/>
|
||||||
|
<input type="hidden" name="offset" value="0"/>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<!-- Bookshelf Container -->
|
<!-- Books Grid -->
|
||||||
<div id="bookshelf-container">
|
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||||
<!-- Loading state -->
|
<!-- Books will be loaded here via HTMX -->
|
||||||
<div id="loading" class="text-center py-16" style="color: var(--text-secondary)">
|
</div>
|
||||||
<div class="loading-spinner mx-auto mb-4"></div>
|
<!-- Pagination -->
|
||||||
<p>Loading your library...</p>
|
<div id="pagination" class="mt-6 flex justify-center gap-2">
|
||||||
|
<!-- Pagination will be loaded here via HTMX -->
|
||||||
|
</div>
|
||||||
|
<!-- Error Message -->
|
||||||
|
if errorMessage != "" {
|
||||||
|
<div class="mt-6 p-4 rounded-lg border bg-red-500/10 border-red-500">
|
||||||
|
<p style="color: var(--text-primary)">{ errorMessage }</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Empty state -->
|
}
|
||||||
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
</div>
|
||||||
<div class="text-6xl mb-4">📚</div>
|
<!-- Save Filter Modal -->
|
||||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Yet</h3>
|
<div
|
||||||
<p>Select a library to view your collection</p>
|
x-show="showSaveModal"
|
||||||
</div>
|
x-transition
|
||||||
<!-- Books Grid -->
|
@click.self="showSaveModal = false"
|
||||||
<div id="books-grid" class="hidden">
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
<!-- Books will be loaded here on shelves -->
|
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
@click.stop
|
||||||
|
class="card rounded-lg p-6 w-full max-w-md mx-4"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2>
|
||||||
|
<button
|
||||||
|
@click="showSaveModal = false"
|
||||||
|
class="p-2 hover:opacity-80 rounded"
|
||||||
|
style="color: var(--text-primary)"
|
||||||
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
<form @submit="saveFilter($event)">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
|
Filter Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="filterName"
|
||||||
|
placeholder="My Custom Filter"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showSaveModal = false"
|
||||||
|
class="px-4 py-2 rounded-lg border"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Load saved bookshelf script -->
|
||||||
|
<script src="/static/bookshelf.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+89
-185
@@ -1,200 +1,104 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
|
async function loadSavedFilters(): Promise<void> {
|
||||||
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<void> {
|
|
||||||
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 += `<div class="relative bg-gradient-to-b from-transparent to-black/10 p-8 mb-4 rounded-lg" style="padding-bottom: 3rem;">
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
|
||||||
${shelfBooks.map((book: any) => renderBookCard(book)).join("")}
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-3 rounded-b-lg" style="background: linear-gradient(to bottom, rgba(107, 68, 35, 0.3) 0%, rgba(107, 68, 35, 0.5) 50%, rgba(107, 68, 35, 0.3) 100%); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);"></div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
booksGrid.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBookCard(book: any): string {
|
|
||||||
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
|
|
||||||
const authorHtml = book.author
|
|
||||||
? `<p class="text-xs" style="color: var(--text-secondary)">${book.author}</p>`
|
|
||||||
: "";
|
|
||||||
return `<div class="relative transition-all duration-200 ease hover:-translate-y-2 hover:-rotate-2 hover:shadow-2xl hover:z-10 cursor-pointer" data-book-id="${book.id}">
|
|
||||||
<div class="aspect-[2/3] overflow-hidden rounded shadow-[2px_2px_4px_rgba(0,0,0,0.2),-1px_-1px_2px_rgba(255,255,255,0.1)_inset] relative">
|
|
||||||
<div class="absolute left-0 top-0 bottom-0 w-1" style="background: linear-gradient(to right, rgba(0, 0, 0, 0.3) 0%, rgba(255, 255, 255, 0.1) 50%, transparent 100%);"></div>
|
|
||||||
<img src="${coverUrl}"
|
|
||||||
alt="${book.title}"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
onerror="this.src='/static/placeholder-book.svg'"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary)">${book.title}</h3>
|
|
||||||
${authorHtml}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<void> {
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch("/api/bookshelf/filters", {
|
||||||
`/api/media-items?library_id=${libraryId}&limit=50&offset=${offset}`,
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const filters = await response.json();
|
||||||
mediaItems = data;
|
localStorage.setItem("bookshelfFilters", JSON.stringify(filters));
|
||||||
renderBookshelf();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load bookshelf:", error);
|
console.error("Failed to load saved filters:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Note: saveFilter and showSaveFilterModal are now methods on the Alpine component
|
||||||
function setupEventDelegation(): void {
|
// They access state via 'this' instead of window.Alpine
|
||||||
const container = document.getElementById("books-grid");
|
function clearFilters(): void {
|
||||||
if (!container) return;
|
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||||
|
if (!filterForm) return;
|
||||||
container.addEventListener("click", (e) => {
|
const inputs = filterForm.querySelectorAll("input, select");
|
||||||
const target = e.target as HTMLElement;
|
inputs.forEach((input) => {
|
||||||
const card = target.closest("[data-book-id]") as HTMLElement;
|
if (input instanceof HTMLInputElement && input.type === "checkbox") {
|
||||||
|
input.checked = false;
|
||||||
if (card) {
|
} else {
|
||||||
const bookId = card.dataset.bookId;
|
(input as HTMLInputElement).value = "";
|
||||||
if (bookId) {
|
|
||||||
selectBook(bookId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
htmx.trigger(filterForm, "change");
|
||||||
}
|
}
|
||||||
|
// Alpine.js component - all state managed locally, no window.Alpine at runtime
|
||||||
export {
|
|
||||||
changePage,
|
|
||||||
initBookshelf,
|
|
||||||
loadBookshelf,
|
|
||||||
selectBook,
|
|
||||||
selectLibrary,
|
|
||||||
setupEventDelegation,
|
|
||||||
viewBook,
|
|
||||||
};
|
|
||||||
|
|
||||||
Alpine.data("bookshelf", () => ({
|
Alpine.data("bookshelf", () => ({
|
||||||
// State Variables
|
showSaveModal: false,
|
||||||
isLoading: true,
|
filterName: "",
|
||||||
hasBooks: false,
|
initBookshelf(): void {
|
||||||
|
loadSavedFilters();
|
||||||
// Methods
|
const librarySelect = document.getElementById(
|
||||||
changePage,
|
"library-select",
|
||||||
initBookshelf,
|
) as HTMLSelectElement;
|
||||||
loadBookshelf,
|
if (librarySelect && librarySelect.value) {
|
||||||
selectBook,
|
const filterForm = document.getElementById(
|
||||||
selectLibrary,
|
"filter-form",
|
||||||
setupEventDelegation,
|
) as HTMLFormElement;
|
||||||
viewBook,
|
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<void> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user