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.
443 lines
14 KiB
TypeScript
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 };
|