// 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 = 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 { 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 { await this.fetchFieldValues("author", input.value, "author-datalist"); }, // Fetch tag values for autocomplete async fetchTagValues(input: HTMLInputElement): Promise { await this.fetchFieldValues("tags", input.value, "tags-datalist"); }, // // Fetch genre values for autocomplete // async fetchGenreValues(input: HTMLInputElement): Promise { // await this.fetchFieldValues("genre", input.value, "genre-datalist"); // }, // Fetch series values for autocomplete async fetchSeriesValues(input: HTMLInputElement): Promise { await this.fetchFieldValues("series", input.value, "series-datalist"); }, // Fetch language values for autocomplete async fetchLanguageValues(input: HTMLInputElement): Promise { 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 };