Major refactoring of bookshelf filter logic: Filter loading improvements: - Add clearFormWithoutSubmit() helper to reset form without submission - Refactor clearFilters() to reuse clearFormWithoutSubmit() helper Reduces code duplication from 38 lines to 8 lines - Update loadFilter() to call clearFormWithoutSubmit() before populating This ensures all stale data from previous filter is cleared - Move has_cover handling before empty value check Fixes issue where has_cover=false was being skipped - Remove automatic HTMX trigger note from cycleHasCover() Fixed issues: - Author field staying populated when switching to filter without author - has_cover tristate button not updating when switching between filters - has_cover button not updating from "Has Cover" to "Any" when loading filter without has_cover - General stale data retention when loading different saved filters HTMX event handling: - Add event listener in initBookshelf() for htmx:afterSwap events - Listens on #saved-filters-list element (the swap target) - Calls afterFilterSave() to close modal and show success toast - Properly handles Alpine component state access All filter operations now work correctly with proper state management and no visual artifacts from previous filters.
398 lines
12 KiB
TypeScript
398 lines
12 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,
|
|
|
|
// Standalone function references (don't access component state)
|
|
clearFilters,
|
|
initBookshelf,
|
|
|
|
// Inline method - opens the modal
|
|
showSaveFilterModal() {
|
|
this.showSaveModal = true;
|
|
},
|
|
// Toggle the filters dropdown
|
|
toggleFiltersDropdown() {
|
|
this.showFiltersDropdown = !this.showFiltersDropdown;
|
|
},
|
|
// 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;
|
|
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 };
|