Files
bookhoard/web/src/bookshelf.ts
T
john-okeefe 9dccdfbde0 Fix load filter dropdown positioning on bookshelf page
The load filter dropdown was being cut off when the button was positioned
on the left side of the screen due to static right-0 alignment. This became
more problematic as the button position changes with window resize.

Changes:
- Added dynamic dropdown alignment calculation based on button position
  and available viewport space
- Implemented smart positioning logic that checks available space on both
  left and right sides before deciding alignment
- Added window resize listener using requestAnimationFrame to dynamically
  update dropdown position while open
- Added data-load-filter-btn attribute for reliable DOM querying
- Changed from static right-0 to dynamic :class binding for left/right
  alignment

Technical details:
- Alpine.js state: dropdownAlign tracks current alignment (left/right)
- calculateAlignment() method computes button position and available space
- Uses getBoundingClientRect() to measure button position relative to viewport
- Prefers side with >=320px space, otherwise chooses larger side
- requestAnimationFrame ensures smooth updates during resize without
  performance degradation

Fixes issue where dropdown extends beyond viewport edge when button
is near left or right edge of screen.
2026-03-28 20:35:40 -04:00

443 lines
14 KiB
TypeScript

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