Files
bookhoard/web/src/bookshelf.ts
T
john-okeefe 4ea110cfeb refactor: replace genre with tags in frontend TypeScript
- 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
2026-03-25 20:38:21 -04:00

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 };