Files
bookhoard/web/src/bookshelf.ts
T
John O'Keefe 77cd268f4e style: format web/src with oxfmt defaults (via vp fmt)
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).
2026-10-21 13:18:24 -04:00

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 };