Files
bookhoard/web/src/bookshelf.ts
T
john-okeefe d222257797 feat(ui): persist library selection across page navigation
Save the selected library to localStorage when the user changes the
dropdown on the bookshelf page, and restore it on every page load via
a new restoreLibrarySelection() call in the header Alpine component.

This ensures that when a user navigates between dashboard, bookshelf,
collections, etc., their last-chosen library filter is automatically
re-applied rather than resetting to the default.

Changes:
- web/src/bookshelf.ts: listen for change events on #library-select
  and persist the value to localStorage
- web/src/header.ts: add restoreLibrarySelection() which checks
  localStorage and sets the matching dropdown option on page load
- templates/header.templ: call restoreLibrarySelection() in x-init
- templates/header_templ.go: regenerated from templ source
2026-04-26 21:21:40 -04:00

446 lines
14 KiB
TypeScript

// Bookshelf functionality - procedural/imperative style
import { Alpine } from "./alpine";
import { showToast } from "./toast";
function clearFilters(): void {
// Clear all form state (reuses helper)
clearFormWithoutSubmit();
// Trigger form submit with cleared filters
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) return;
window.htmx.trigger(filterForm, "submit");
}
// Remove dynamically created hidden inputs from filter form
// Preserves: limit, offset, has_cover (static/managed inputs)
function cleanupDynamicHiddenInputs(): void {
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) return;
// Names of inputs to preserve (static or Alpine-managed)
const preserveNames = ["limit", "offset", "has_cover"];
// Find all hidden inputs
const hiddenInputs = filterForm.querySelectorAll('input[type="hidden"]');
hiddenInputs.forEach((input) => {
const name = (input as HTMLInputElement).name;
// Remove if it's not in our preserve list and has a name
if (name && !preserveNames.includes(name)) {
input.remove();
}
});
}
// Clear form fields WITHOUT submitting
// Used by loadFilter to reset state before applying new filter
function clearFormWithoutSubmit(): void {
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) return;
// Reset all form fields to their HTML defaults
filterForm.reset();
// Remove has_cover input entirely
const hasCoverInput = document.querySelector(
'[name="has_cover"]',
) as HTMLInputElement;
if (hasCoverInput) {
hasCoverInput.remove();
}
// Reset Alpine hasCoverState to null
const bookshelfComponent = document.querySelector('[x-data*="bookshelf"]');
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
const data = (bookshelfComponent as any)._x_dataStack[0];
if (data && typeof data.hasCoverState !== "undefined") {
data.hasCoverState = null;
}
}
// Remove any hidden inputs from previous filter loads
cleanupDynamicHiddenInputs();
// Reset pagination to defaults
const limitInput = filterForm.querySelector(
'input[name="limit"]',
) as HTMLInputElement;
const offsetInput = filterForm.querySelector(
'input[name="offset"]',
) as HTMLInputElement;
if (limitInput) limitInput.value = "50";
if (offsetInput) offsetInput.value = "0";
// NOTE: No form submit here!
}
// Alpine.js component
Alpine.data("bookshelf", () => ({
// Component state
showSaveModal: false,
showFiltersDropdown: false,
hasCoverState: null as boolean | null,
dropdownAlign: "right" as "left" | "right",
resizeTimeout: null as number | null,
// Standalone function references (don't access component state)
clearFilters,
initBookshelf,
// Inline method - opens the modal
showSaveFilterModal() {
this.showSaveModal = true;
},
// Helper method to calculate alignment
calculateAlignment() {
const button = document.querySelector("[data-load-filter-btn]");
if (!button) return;
const rect = button.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const dropdownWidth = 320;
const spaceOnLeft = rect.left;
const spaceOnRight = viewportWidth - rect.right;
if (spaceOnRight >= dropdownWidth) {
this.dropdownAlign = "left";
} else if (spaceOnLeft >= dropdownWidth) {
this.dropdownAlign = "right";
} else {
this.dropdownAlign = spaceOnLeft > spaceOnRight ? "right" : "left";
}
},
toggleFiltersDropdown() {
this.showFiltersDropdown = !this.showFiltersDropdown;
if (this.showFiltersDropdown) {
this.$nextTick(() => {
this.calculateAlignment();
});
}
},
// Alpine lifecycle hook - runs when component initializes
init() {
let rafId: number | null = null;
window.addEventListener("resize", () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
if (this.showFiltersDropdown) {
this.calculateAlignment();
}
rafId = null;
});
});
},
// Cycle through has_cover states: null -> true -> false -> null
cycleHasCover(): void {
if (this.hasCoverState === null) {
this.hasCoverState = true;
} else if (this.hasCoverState === true) {
this.hasCoverState = false;
} else {
this.hasCoverState = null;
}
// NOTE: We don't trigger form submission here
// The user must click "Search" or press Enter to apply the filter
},
// Handle post-HTMX swap cleanup for saved filters
afterFilterSave(): void {
// Hide empty state if it exists
const filtersList = document.getElementById("saved-filters-list");
if (filtersList) {
const emptyState = filtersList.nextElementSibling;
if (emptyState && emptyState.textContent?.includes("No saved filters")) {
emptyState.classList.add("hidden");
}
}
// Close modal
this.showSaveModal = false;
// Show success toast
showToast("Filter saved successfully", "success");
},
// Load a saved filter into the form
async loadFilter(event: Event) {
const button = event.target as HTMLElement;
const filterRow = button.closest("[data-filter-id]");
if (!filterRow) return;
const filterId = filterRow?.getAttribute("data-filter-id");
if (!filterId) return;
const filterName = button.textContent?.trim() || "";
showToast(`Loading filter: ${filterName}...`, "info");
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
try {
// Fetch filter details from API
const response = await fetch(`/api/saved-filters/${filterId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
if (response.status === 404) {
showToast("Filter not found", "error");
} else {
showToast("Failed to load filter", "error");
}
return;
}
const filter = await response.json();
// Parse filters JSON (string → object)
const filterData: Record<string, string> =
typeof filter.filters === "string"
? JSON.parse(filter.filters)
: filter.filters;
// Get the hidden filter form
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement;
if (!filterForm) {
showToast("Filter form not found", "error");
return;
}
// Clean up any hidden inputs from previous filter loads
cleanupDynamicHiddenInputs();
// Clear all form state before applying new filter
clearFormWithoutSubmit();
// Don't clear form - just update values
// Populate form fields from filter data
Object.entries(filterData).forEach(([key, value]) => {
if (key === "has_cover") {
const bookshelfComponent = document.querySelector(
'[x-data*="bookshelf"]',
);
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
const data = (bookshelfComponent as any)._x_dataStack[0];
if (data && typeof data.hasCoverState !== "undefined") {
// Convert string value to boolean|null
if (value === "true") {
data.hasCoverState = true;
} else if (value === "false") {
data.hasCoverState = false;
} else {
data.hasCoverState = null;
}
}
}
return; // Don't process has_cover like other fields
}
if (!value) return; // Skip empty values
// Special handling for has_cover - update Alpine state directly
// Handle all other fields normally
const visibleField = filterForm.querySelector(
`[name="${key}"]`,
) as HTMLInputElement;
if (visibleField) {
if (visibleField.type === "checkbox") {
visibleField.checked = value === "true";
} else {
visibleField.value = value;
}
} else {
// Create hidden input if visible field doesn't exist
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value;
filterForm.appendChild(input);
}
});
// Trigger HTMX to apply the filter by submitting the form
window.htmx.trigger(filterForm, "submit");
showToast(`Filter applied: ${filterName}`, "success");
// Close the dropdown after applying
this.showFiltersDropdown = false;
} catch (error) {
console.error("Failed to load filter:", error);
showToast("Error loading filter", "error");
}
},
// Delete a saved filter
async deleteFilter(event: Event) {
const button = event.target as HTMLElement;
const filterRow = button.closest("[data-filter-id]");
if (!filterRow) return;
const filterId = filterRow?.getAttribute("data-filter-id");
if (!filterId) return;
if (!confirm("Are you sure you want to delete this filter?")) return;
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
try {
const response = await fetch(`/api/saved-filters/${filterId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Filter deleted", "success");
// Remove the element from DOM
filterRow?.remove();
} else {
showToast("Failed to delete filter", "error");
}
} catch (error) {
console.error("Failed to delete filter:", error);
showToast("Error deleting filter", "error");
}
},
// Autocomplete helper function
async fetchFieldValues(
field: string,
search: string,
datalistId: string,
): Promise<void> {
const token = localStorage.getItem("token");
if (!token) {
console.error("Not authenticated");
return;
}
if (search.length < 2) return;
const currentLibraryId = (
document.getElementById("library-select") as HTMLSelectElement
)?.value;
if (!currentLibraryId) {
console.error("No library selected");
return;
}
try {
const response = await fetch(
`/api/media-items/search?${field}=${encodeURIComponent(
search,
)}&library_id=${currentLibraryId}&limit=50`,
{
headers: { Authorization: `Bearer ${token}` },
},
);
if (!response.ok) {
console.error("Failed to fetch field values");
return;
}
const data = await response.json();
// Update datalist via DOM manipulation
const datalist = document.getElementById(datalistId);
if (!datalist) {
console.error(`Datalist ${datalistId} not found`);
return;
}
// Clear existing options
datalist.innerHTML = "";
// Add new options
data.results.forEach((item: { value: string; count: number }) => {
const option = document.createElement("option");
option.value = item.value;
option.textContent = `${item.value} (${item.count})`;
datalist.appendChild(option);
});
} catch (error) {
console.error("Error fetching field values:", error);
}
},
// Fetch author values for autocomplete
async fetchAuthorValues(input: HTMLInputElement): Promise<void> {
await this.fetchFieldValues("author", input.value, "author-datalist");
},
// Fetch tag values for autocomplete
async fetchTagValues(input: HTMLInputElement): Promise<void> {
await this.fetchFieldValues("tags", input.value, "tags-datalist");
},
// // Fetch genre values for autocomplete
// async fetchGenreValues(input: HTMLInputElement): Promise<void> {
// await this.fetchFieldValues("genre", input.value, "genre-datalist");
// },
// Fetch series values for autocomplete
async fetchSeriesValues(input: HTMLInputElement): Promise<void> {
await this.fetchFieldValues("series", input.value, "series-datalist");
},
// Fetch language values for autocomplete
async fetchLanguageValues(input: HTMLInputElement): Promise<void> {
await this.fetchFieldValues("language", input.value, "language-datalist");
},
}));
// Standalone function - initialize the bookshelf (NOT async, like dashboard.ts)
function initBookshelf(): void {
// Check if books were already rendered server-side
const booksGrid = document.getElementById("books-grid");
const hasServerBooks =
booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
if (!hasServerBooks) {
// Only trigger HTMX if no SSR books rendered
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
librarySelect?.addEventListener("change", () => {
localStorage.setItem("selectedLibrary", librarySelect.value);
});
if (librarySelect && librarySelect.value) {
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement;
if (filterForm) {
// Trigger initial HTMX load
window.htmx.trigger(librarySelect, "change");
}
}
}
// Setup listener for HTMX swap events on the filters list
const filtersList = document.getElementById("saved-filters-list");
if (filtersList) {
filtersList.addEventListener("htmx:afterSwap", () => {
const bookshelfElem = document.querySelector("[x-data*='bookshelf']");
if (!bookshelfElem) return;
const bookshelf = (bookshelfElem as any)._x_dataStack?.[0];
if (!bookshelf) return;
bookshelf.afterFilterSave();
});
}
}
export { clearFilters, cleanupDynamicHiddenInputs, clearFormWithoutSubmit };