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:
2026-03-16 16:24:03 -04:00
parent 30c8132c96
commit 5782a4e314
4 changed files with 489 additions and 246 deletions
+89 -185
View File
@@ -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<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> {
async function loadSavedFilters(): Promise<void> {
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<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 };