- Add fetchTagValues() function in bookshelf.ts - Update custom-section-builder field id from "genre" to "tags" - Genre code preserved as comments for easy restoration if needed - collection-rules.ts already supports both genre and tags Updates the frontend TypeScript to use tags instead of genre for filtering. Genre code is preserved in comments for future use if the genre field is populated. Relates to IMPLEMENTATION_TAGS_FILTER.md Phase 4
343 lines
9.8 KiB
TypeScript
343 lines
9.8 KiB
TypeScript
// Bookshelf functionality - procedural/imperative style
|
|
|
|
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
|
|
function clearFilters(): void {
|
|
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
|
if (!filterForm) return;
|
|
|
|
// Reset all form fields
|
|
const inputs = filterForm.querySelectorAll("input, select");
|
|
inputs.forEach((input) => {
|
|
if (input instanceof HTMLInputElement && input.type === "checkbox") {
|
|
input.checked = false;
|
|
} else {
|
|
(input as HTMLInputElement).value = "";
|
|
}
|
|
});
|
|
|
|
// Trigger HTMX reload with cleared filters
|
|
window.htmx.trigger(filterForm, "change");
|
|
}
|
|
|
|
// Alpine.js component
|
|
Alpine.data("bookshelf", () => ({
|
|
// Component state
|
|
showSaveModal: false,
|
|
filterName: "",
|
|
showFiltersDropdown: false,
|
|
|
|
// Standalone function references (don't access component state)
|
|
clearFilters,
|
|
initBookshelf,
|
|
|
|
// Inline method - opens the modal
|
|
showSaveFilterModal() {
|
|
this.showSaveModal = true;
|
|
},
|
|
// Toggle the filters dropdown
|
|
toggleFiltersDropdown() {
|
|
this.showFiltersDropdown = !this.showFiltersDropdown;
|
|
},
|
|
// 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;
|
|
}
|
|
|
|
// Clear existing filter values
|
|
filterForm.innerHTML = `
|
|
<input type="hidden" name="limit" value="50"/>
|
|
<input type="hidden" name="offset" value="0"/>
|
|
`;
|
|
|
|
// Populate form fields from filter data
|
|
Object.entries(filterData).forEach(([key, value]) => {
|
|
if (value) {
|
|
// Only set non-empty values
|
|
const input = document.createElement("input");
|
|
input.type = "hidden";
|
|
input.name = key;
|
|
input.value = value;
|
|
filterForm.appendChild(input);
|
|
|
|
// Also update visible form fields if they exist
|
|
const visibleField = document.querySelector(
|
|
`[name="${key}"]`,
|
|
) as HTMLInputElement;
|
|
if (visibleField) {
|
|
visibleField.value = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Trigger HTMX to apply the filter
|
|
// Use the first input to trigger the change event
|
|
const firstInput = filterForm.querySelector("input");
|
|
if (firstInput) {
|
|
window.htmx.trigger(firstInput, "change");
|
|
}
|
|
|
|
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");
|
|
}
|
|
},
|
|
// Inline method - saves the filter and closes modal
|
|
async saveFilter(event: Event) {
|
|
event.preventDefault();
|
|
|
|
const token = localStorage.getItem("token");
|
|
if (!token) {
|
|
showToast("Not authenticated", "error");
|
|
return;
|
|
}
|
|
// Get filter name from component state
|
|
const filterName = this.filterName;
|
|
if (!filterName) {
|
|
showToast("Please enter a filter name", "error");
|
|
return;
|
|
}
|
|
|
|
// Collect filter form data
|
|
const filterForm = document.getElementById(
|
|
"filter-form",
|
|
) as HTMLFormElement;
|
|
if (!filterForm) {
|
|
showToast("Filter form not found", "error");
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData(filterForm);
|
|
const filterData: Record<string, string> = {};
|
|
formData.forEach((value, key) => {
|
|
filterData[key] = value.toString();
|
|
});
|
|
|
|
try {
|
|
const response = await fetch("/api/saved-filters", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
name: filterName,
|
|
resource_type: "media-items",
|
|
filters: filterData,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast("Filter saved successfully", "success");
|
|
// Clear the filter name input
|
|
this.filterName = "";
|
|
// Close the modal
|
|
this.showSaveModal = false;
|
|
} else {
|
|
showToast("Failed to save filter", "error");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to save filter:", error);
|
|
showToast("Error saving 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");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export { clearFilters };
|