- storage.ts: Centralize ALL_LIBRARIES = "__all__" sentinel constant. setSelectedLibrary() now writes both localStorage and a cookie (selectedLibrary, Path=/, SameSite=Lax, max-age=365d). The sentinel "__all__" is used in both storage mediums — empty strings are never stored. getSelectedLibrary() maps __all__ back to "". Cookie enables server-side rendering to read the stored library selection without access to localStorage. - library-switcher.ts: Import ALL_LIBRARIES and setSelectedLibrary/ getSelectedLibrary from storage.ts instead of managing localStorage directly. Remove local constants. - dashboard.ts: Remove duplicate localStorage.setItem call that was overwriting the __all__ sentinel with raw empty string. Fix reloadPage() and scan-complete handler to work with empty libraryId. openDashboardSettings/saveDashboardSettings show clear messages for All Libraries mode. - collections.ts: Remove library switcher initialization from the collections list page — the list page no longer has a switcher. - series.ts: Rewrite to use initLibrarySwitcher from library-switcher module and switchWithTransition for navigation. Series card links no longer include library_id in their URLs. - bookshelf.ts: Autocomplete fetch calls handle empty libraryId correctly for All Libraries mode. - search.ts, collection-rules.ts: Use setSelectedLibrary() and getSelectedLibrary() from storage.ts instead of direct localStorage access.
492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
// Bookshelf functionality - procedural/imperative style
|
|
|
|
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
|
|
|
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,
|
|
tagSuggestions: [] as TagSuggestion[],
|
|
showTagDropdown: false,
|
|
tagHighlightIndex: -1,
|
|
|
|
// 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;
|
|
|
|
const libParam = currentLibraryId ? `&library_id=${currentLibraryId}` : "";
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/media-items/search?${field}=${encodeURIComponent(
|
|
search,
|
|
)}${libParam}&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 (custom dropdown)
|
|
async fetchTagValues(input: HTMLInputElement): Promise<void> {
|
|
const libraryId = (document.getElementById("library-select") as HTMLSelectElement)?.value;
|
|
if (!libraryId || input.value.length < 2) {
|
|
this.tagSuggestions = [];
|
|
this.showTagDropdown = false;
|
|
return;
|
|
}
|
|
const results = await searchTagSuggestions(input.value, libraryId);
|
|
this.tagSuggestions = results;
|
|
this.showTagDropdown = results.length > 0;
|
|
this.tagHighlightIndex = -1;
|
|
},
|
|
|
|
selectTagSuggestion(tag: string) {
|
|
const input = document.querySelector('input[name="tags_filter"]') as HTMLInputElement;
|
|
if (input) input.value = tag;
|
|
this.showTagDropdown = false;
|
|
this.tagSuggestions = [];
|
|
this.tagHighlightIndex = -1;
|
|
},
|
|
|
|
hideTagDropdown() {
|
|
setTimeout(() => { this.showTagDropdown = false; this.tagHighlightIndex = -1; }, 200);
|
|
},
|
|
|
|
onTagFilterKeydown(event: KeyboardEvent) {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
|
this.tagHighlightIndex = (this.tagHighlightIndex + 1) % this.tagSuggestions.length;
|
|
}
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
|
this.tagHighlightIndex = this.tagHighlightIndex <= 0
|
|
? this.tagSuggestions.length - 1
|
|
: this.tagHighlightIndex - 1;
|
|
}
|
|
} else if (event.key === "Enter" && this.tagHighlightIndex >= 0 && this.showTagDropdown) {
|
|
event.preventDefault();
|
|
this.selectTagSuggestion(this.tagSuggestions[this.tagHighlightIndex].value);
|
|
} else if (event.key === "Escape") {
|
|
this.showTagDropdown = false;
|
|
this.tagSuggestions = [];
|
|
this.tagHighlightIndex = -1;
|
|
}
|
|
},
|
|
|
|
// // 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 };
|