// Bookshelf functionality - procedural/imperative style import { Alpine } from "./alpine"; import { showToast } from "./toast"; import type { FilterGroupState, FilterRuleState } from "./filter_builder"; function clearFilters(): void { // Clear all form state (reuses helper) clearFormWithoutSubmit(); // Reset the rule builder tree (the builder's wire inputs are // serializer-managed and already dropped by cleanupDynamicHiddenInputs) builderData()?.clearBuilder(); // Trigger form submit with cleared filters const filterForm = document.getElementById("filter-form") as HTMLFormElement; if (!filterForm) return; window.htmx.trigger(filterForm, "submit"); } // builderData returns the rule builder's Alpine data (nested x-data // inside the drawer) — the same DOM-lookup pattern the bookshelf // component uses. function builderData(): any | null { const el = document.querySelector('[x-data*="filterBuilderExtra"]'); if (!el || !(el as any)._x_dataStack) return null; return (el as any)._x_dataStack[0] ?? null; } // Remove dynamically created hidden inputs from filter form // Preserves: limit, offset (static/managed inputs). The builder's wire // inputs (flat facet params + qf) are serializer-managed and dropped so // the next serialization starts clean. 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"]; // 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 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, filtersOpen: false, dropdownAlign: "right" as "left" | "right", resizeTimeout: null as number | null, // Active-filter chips (Batch B): Alpine renders them from this state; // refreshed by refreshFilterChips after every grid refetch. activeFilters: [] as FilterChip[], // Standalone function references (don't access component state) clearFilters, initBookshelf, // Inline method - opens the modal showSaveFilterModal() { this.showSaveModal = true; }, // Remove one active-filter chip: clear that facet's field (hidden // inputs are dropped outright) and re-apply the form. Chips refresh // via the shared htmx:afterRequest hook. removeFilterChip(param: string): void { const filterForm = document.getElementById("filter-form") as HTMLFormElement; if (!filterForm) return; // Tree chips (qf mode): "rule:" — remove that rule from the // builder tree, resync the wire inputs, re-apply. if (param.startsWith("rule:")) { const builder = builderData(); const path = param .slice(5) .split("-") .map((n) => parseInt(n, 10)); builder?.removeNode(path); window.htmx.trigger(filterForm, "submit"); return; } const input = filterForm.querySelector(`[name="${param}"]`) as HTMLInputElement | null; if (input) { if (input.type === "hidden") { input.remove(); } else { input.value = ""; } } window.htmx.trigger(filterForm, "submit"); }, // 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; }); }); }, // 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. Every saved param lands // as a (hidden) form input — the builder's hydration reads them // (qf wins over flat, the pinned precedence). Object.entries(filterData).forEach(([key, value]) => { if (!value) return; // Skip empty values 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); } }); // Rehydrate the rule-builder tree from the refilled form builderData()?.hydrateFromForm(); // 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"); } }, })); // Standalone function - initialize the bookshelf (NOT async, like dashboard.ts) // A2c §5.3: deep-linkable filters. Reads known filter params from the URL // into the form inputs; returns true when at least one was applied. A // comma-joined value is a multi-select (the server splits it per element). // Facet names mirror services/facet_registry.go; params without a visible // drawer input (Batch A facets, bool facets) land as hidden inputs like // loadFilter's, so chips and clearing work for every deep link. function applyURLFilters(filterForm: HTMLFormElement | null): boolean { if (!filterForm) return false; const params = new URLSearchParams(window.location.search); // Rule-builder trees ride the qf param (faceted plan Phase 9). // Precedence (pinned): when qf is present the flat params are // ignored — the tree IS the query. const qf = params.get("qf"); if (qf) { const input = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null; if (input) { input.value = qf; } else { const hidden = document.createElement("input"); hidden.type = "hidden"; hidden.name = "qf"; hidden.value = qf; filterForm.appendChild(hidden); } builderData()?.hydrateFromForm(); return true; } const filterNames = [ "author_filter", "series_filter", "genre_filter", "publisher_filter", "tags_filter", "narrator_filter", "language_filter", "story_arc_filter", "imprint_filter", "edition_filter", "age_rating_filter", "format_group_filter", "manga_type_filter", "reading_direction_filter", "explicit_filter", "abridged_filter", "year_min", "year_max", "qf", ]; let applied = false; for (const name of filterNames) { const value = params.get(name); if (!value) continue; const input = filterForm.querySelector(`[name="${name}"]`) as HTMLInputElement | null; if (input) { input.value = value; applied = true; } else if (name !== "has_cover") { // No visible drawer input (Batch C's panel will manage these // facets): hold the value in a hidden input so the form submit // carries it and a chip can offer removal. const hidden = document.createElement("input"); hidden.type = "hidden"; hidden.name = name; hidden.value = value; filterForm.appendChild(hidden); applied = true; } } return applied; } // Active-filter chips (Batch B): a pure function of the filter form's // client state — Alpine renders them from `activeFilters`, templ owns // the markup. Removing a chip clears that facet and re-applies the form. interface FilterChip { param: string; label: string; // Tree chips only: negated operators render red. negated: boolean; } // prettifyGenreLabel ports templates.prettifyGenre (Batch B transform): // only entirely-lowercase values are title-cased; small words stay down // except in first position; anything else displays verbatim. function prettifyGenreLabel(value: string): string { const trimmed = value.trim(); if (!trimmed || trimmed !== trimmed.toLowerCase()) return trimmed; const small = new Set(["a", "an", "and", "of", "the", "in", "on", "for", "to", "or"]); return trimmed .split(/\s+/) .map((w, i) => (i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1))) .join(" "); } // facetChipLabel builds one chip's shown text. function facetChipLabel(name: string, value: string): string { if (name === "has_cover") { return value === "true" ? "Has cover" : "No cover"; } const names: Record = { author_filter: "Author", publisher_filter: "Publisher", narrator_filter: "Narrator", tags_filter: "Tags", series_filter: "Series", genre_filter: "Genre", language_filter: "Language", year_min: "From year", year_max: "To year", story_arc_filter: "Story arc", imprint_filter: "Imprint", edition_filter: "Edition", age_rating_filter: "Age rating", format_group_filter: "Format", manga_type_filter: "Manga type", reading_direction_filter: "Reading direction", explicit_filter: "Explicit", abridged_filter: "Abridged", }; const label = names[name] ?? name; const shown = name === "genre_filter" ? prettifyGenreLabel(value) : value; return `${label}: ${shown}`; } // refreshFilterChips rebuilds `activeFilters` from the form and syncs // the shareable URL (history.replaceState) — the single sync point for // form applies, URL deep-links, sort changes, and clears. function refreshFilterChips(): void { const filterForm = document.getElementById("filter-form") as HTMLFormElement; const bookshelfElem = document.querySelector('[x-data*="bookshelf"]'); if (!filterForm || !bookshelfElem || !(bookshelfElem as any)._x_dataStack) { return; } const data = (bookshelfElem as any)._x_dataStack[0]; if (typeof data.activeFilters === "undefined") return; const chips: FilterChip[] = []; const qfInput = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null; if (qfInput && qfInput.value) { // Tree mode: one chip per rule (red for negations — the chips // template styles them via chip.negated). Values that are IDs // (collection/library) label through the builder's id→name cache. const builder = builderData(); try { const tree = JSON.parse(qfInput.value) as FilterGroupState; const walk = (group: FilterGroupState, path: number[]) => { (group.children as (FilterRuleState | FilterGroupState)[]).forEach((child, idx) => { const childPath = [...path, idx]; if ("children" in (child as FilterGroupState)) { walk(child as FilterGroupState, childPath); return; } const rule = child as FilterRuleState; const label = builder ? builder.chipLabelFor(rule) : `${rule.field} ${rule.op}`; chips.push({ param: "rule:" + childPath.join("-"), label, negated: builder?.isNegated(rule) ?? false, }); }); }; walk(tree, []); } catch { // malformed qf — no tree chips } } else { filterForm .querySelectorAll( '[name$="_filter"], [name="year_min"], [name="year_max"], [name="has_cover"]', ) .forEach((input) => { if (input.value) { chips.push({ param: input.name, label: facetChipLabel(input.name, input.value), negated: false, }); } }); } data.activeFilters = chips; // URL stays in sync with the applied set — the filtered view remains // shareable (plan: chips bar is URL-synced). const url = new URL(window.location.href); const keep = new Set([ "author_filter", "series_filter", "genre_filter", "publisher_filter", "tags_filter", "narrator_filter", "language_filter", "story_arc_filter", "imprint_filter", "edition_filter", "age_rating_filter", "format_group_filter", "manga_type_filter", "reading_direction_filter", "explicit_filter", "abridged_filter", "year_min", "year_max", "has_cover", "library_id", "qf", ]); const next = new URLSearchParams(); url.searchParams.forEach((value, key) => { if (keep.has(key)) next.set(key, value); }); if (chips.length === 0 && !next.get("library_id")) { window.history.replaceState(null, "", window.location.pathname); return; } const qs = next.toString(); window.history.replaceState( null, "", qs ? `${window.location.pathname}?${qs}` : window.location.pathname, ); } function initBookshelf(): void { const filterForm = document.getElementById("filter-form") as HTMLFormElement; // Active-filter chips refresh after every grid refetch — drawer // applies, sort changes, and pagination all include the form's // filters, so one bubbled htmx:afterRequest hook is the sync point. filterForm.addEventListener("htmx:afterRequest", () => { refreshFilterChips(); }); // The live match count is scope-aware: switching the top library // picker re-runs it (the count fetch reads the select at fetch time — // this is what makes it fire). const librarySelect = document.getElementById("library-select") as HTMLSelectElement | null; librarySelect?.addEventListener("change", () => { builderData()?.scheduleMatchCount(); }); // A2c §5.3: deep-linkable filters — /bookshelf?author_filter=Sapkowski // pre-fills the form and applies it to the first grid load. Author // links on the detail page (and anything else) can share filtered // views; a comma-joined value is a multi-select (server splits it). // qf deep links rehydrate as the rule tree (qf wins over flat — the // pinned precedence). const urlApplied = applyURLFilters(filterForm); // The builder's tree mirrors the applied wire state (URL or empty) // so the drawer opens with the current rules; the wire inputs are // then serializer-owned. builderData()?.hydrateFromForm(); // 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 || urlApplied) { const librarySelect = document.getElementById("library-select") as HTMLSelectElement; librarySelect?.addEventListener("change", () => { localStorage.setItem("selectedLibrary", librarySelect.value); }); if (urlApplied && filterForm) { // A URL filter replaces the grid with the filtered set — the form // submit serializes the library select too (it lives in the form). window.htmx.trigger(filterForm, "submit"); } else if (librarySelect && librarySelect.value && filterForm) { // Normal unfiltered 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 };