Save the selected library to localStorage when the user changes the dropdown on the bookshelf page, and restore it on every page load via a new restoreLibrarySelection() call in the header Alpine component. This ensures that when a user navigates between dashboard, bookshelf, collections, etc., their last-chosen library filter is automatically re-applied rather than resetting to the default. Changes: - web/src/bookshelf.ts: listen for change events on #library-select and persist the value to localStorage - web/src/header.ts: add restoreLibrarySelection() which checks localStorage and sets the matching dropdown option on page load - templates/header.templ: call restoreLibrarySelection() in x-init - templates/header_templ.go: regenerated from templ source
446 lines
14 KiB
TypeScript
446 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;
|
|
librarySelect?.addEventListener("change", () => {
|
|
localStorage.setItem("selectedLibrary", librarySelect.value);
|
|
});
|
|
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 };
|