One-time mechanical sweep, isolated from any functional change. - oxfmt defaults: double quotes, semicolons, 2-space indent, LF, printWidth 100, trailing commas, bracket spacing, arrow parens. - vite.config.ts gains an fmt.ignorePatterns block for web/static: vendored/minified assets (htmx.min.js, compiled css) must never be reformatted. - Verified: rebuild after the sweep produces byte-identical JS/CSS bundles; only sourcemaps changed (they embed sourcesContent).
601 lines
20 KiB
TypeScript
601 lines
20 KiB
TypeScript
// 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:<path>" — 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<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. 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<string, string> = {
|
|
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<HTMLInputElement>(
|
|
'[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 };
|