diff --git a/web/src/bookshelf.ts b/web/src/bookshelf.ts index 4e51ef7..3412db4 100644 --- a/web/src/bookshelf.ts +++ b/web/src/bookshelf.ts @@ -4,11 +4,65 @@ 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; - // Reset all form fields + 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(); - // Manually reset pagination values + + // 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; @@ -17,16 +71,15 @@ function clearFilters(): void { ) as HTMLInputElement; if (limitInput) limitInput.value = "50"; if (offsetInput) offsetInput.value = "0"; - // Trigger form submit with cleared filters - window.htmx.trigger(filterForm, "submit"); -} + // NOTE: No form submit here! +} // Alpine.js component Alpine.data("bookshelf", () => ({ // Component state showSaveModal: false, - filterName: "", showFiltersDropdown: false, + hasCoverState: null as boolean | null, // Standalone function references (don't access component state) clearFilters, @@ -40,6 +93,33 @@ Alpine.data("bookshelf", () => ({ 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; @@ -91,29 +171,55 @@ Alpine.data("bookshelf", () => ({ 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 (value) { - // Only set non-empty values - // Update visible form fields if they exist - const visibleField = filterForm.querySelector( - `[name="${key}"]`, - ) as HTMLInputElement; - if (visibleField) { - if (visibleField.type === "checkbox") { - visibleField.checked = value === "true"; - } else { - visibleField.value = 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; + } } - } 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); } + 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); } }); @@ -164,65 +270,6 @@ Alpine.data("bookshelf", () => ({ showToast("Error deleting filter", "error"); } }, - // Inline method - saves the filter and closes modal - async saveFilter(event: Event) { - event.preventDefault(); - - const token = localStorage.getItem("token"); - if (!token) { - showToast("Not authenticated", "error"); - return; - } - // Get filter name from component state - const filterName = this.filterName; - if (!filterName) { - showToast("Please enter a filter name", "error"); - return; - } - - // Collect filter form data - const filterForm = document.getElementById( - "filter-form", - ) as HTMLFormElement; - if (!filterForm) { - showToast("Filter form not found", "error"); - return; - } - - const formData = new FormData(filterForm); - const filterData: Record = {}; - formData.forEach((value, key) => { - filterData[key] = value.toString(); - }); - - try { - const response = await fetch("/api/saved-filters", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - name: filterName, - resource_type: "media-items", - filters: filterData, - }), - }); - - if (response.ok) { - showToast("Filter saved successfully", "success"); - // Clear the filter name input - this.filterName = ""; - // Close the modal - this.showSaveModal = false; - } else { - showToast("Failed to save filter", "error"); - } - } catch (error) { - console.error("Failed to save filter:", error); - showToast("Error saving filter", "error"); - } - }, // Autocomplete helper function async fetchFieldValues( @@ -333,6 +380,18 @@ function initBookshelf(): void { } } } + // 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 }; +export { clearFilters, cleanupDynamicHiddenInputs, clearFormWithoutSubmit };