Files
bookhoard/web/src/custom-section-builder.ts
T
John O'Keefe d7d1495753 fix(types): clear web/src type and lint debt with real types
tsc --noEmit and oxlint web/src are now both clean; vp check passes.

- htmx: one canonical Htmx interface in shims.d.ts (ajax/process/trigger
  per the htmx 2.x API). alpine.ts had a second, conflicting Window.htmx
  declaration (TS2717) — removed; bookPicker/collections lose their
  (window as any) escapes now that Window.htmx is properly typed.
- tsconfig lib ES2020 -> ES2021 + DOM.Iterable: replaceAll exists
  (escapeHtml) and for..of over querySelectorAll results is legal.
  Type-checking only — vite emits at esnext regardless.
- custom-section-builder: selectedBooks holds identity fields only;
  typed as Pick<BookInfo, ...> instead of forcing partial API shapes.
  The API-critical BookInfo contract is untouched.
- toast: real bug found by the type system — setup.ts passes 'warning'
  but getToastConfig had no entry, so the warning toast threw TypeError
  at runtime instead of displaying. Added the type + config. Uses
  bg-yellow-500 (verified present in scanned templates => generated by
  Tailwind); the one intentional behavior change in this commit.
- book-detail: instanceof narrowing replaces an as-cast; unused
  AnnotationSource import removed.
- password_validation: explicit HTMLElement | null for the checklist
  list (querySelector('ul') infers HTMLUListElement, conflicting with
  the container-as-list branch).
- stats: tooltip parsed value typed via annotation (bar=scalar,
  line={x,y}); dead getActiveRange() removed (was already tree-shaken
  out of every bundle).
- library-switcher: onSwitch honestly typed void | Promise<void>
  (series.ts passes a sync navigator).
- 20 unused catch bindings converted to optional catch binding.

Verification: fresh build is byte-reproducible; every chunk is
identical to the pre-fix build except main.js (instanceof narrowing,
regex fix, stats refactor), reader.js (destructure), and the shared
storage chunk (renamed — carries toast.ts). reader/main diffs audited
hunk-by-hunk against the intended edits.
2026-10-21 14:17:02 -04:00

630 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiPost } from "./api";
import { showToast } from "./toast";
interface FilterField {
id: string;
label: string;
operators: Operator[];
valueType: "text" | "number" | "date" | "select" | "multiselect";
options?: string[];
}
interface Operator {
id: string;
label: string;
requiresValue: boolean;
}
interface FilterRule {
id: string;
field: string;
operator: string;
value: string | string[];
priority: number;
}
const FILTER_FIELDS: FilterField[] = [
{
id: "title",
label: "Title",
operators: [
{ id: "contains", label: "Contains", requiresValue: true },
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "starts_with", label: "Starts With", requiresValue: true },
{ id: "ends_with", label: "Ends With", requiresValue: true },
{ id: "regex", label: "Matches Regex", requiresValue: true },
],
valueType: "text",
},
{
id: "author",
label: "Author",
operators: [
{ id: "contains", label: "Contains", requiresValue: true },
{ id: "equals", label: "Equals", requiresValue: true },
],
valueType: "text",
},
// {
// id: "genre",
// label: "Genre",
// operators: [
// { id: "equals", label: "Equals", requiresValue: true },
// { id: "not_equals", label: "Not Equals", requiresValue: true },
// { id: "in", label: "In", requiresValue: true },
// { id: "not_in", label: "Not In", requiresValue: true },
// ],
// valueType: "select",
// options: [
// "Fiction",
// "Non-Fiction",
// "Sci-Fi",
// "Fantasy",
// "Mystery",
// "Romance",
// "Thriller",
// "Biography",
// "History",
// "Self-Help",
// ],
// },
{
id: "tags",
label: "Tags",
operators: [
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "not_equals", label: "Not Equals", requiresValue: true },
{ id: "in", label: "In", requiresValue: true },
{ id: "not_in", label: "Not In", requiresValue: true },
],
valueType: "select",
options: [
"Fiction",
"Non-Fiction",
"Sci-Fi",
"Fantasy",
"Mystery",
"Romance",
"Thriller",
"Biography",
"History",
"Self-Help",
],
},
{
id: "series",
label: "Series",
operators: [
{ id: "is_set", label: "Is Set", requiresValue: false },
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "contains", label: "Contains", requiresValue: true },
],
valueType: "text",
},
{
id: "progress",
label: "Reading Progress",
operators: [
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "not_equals", label: "Not Equals", requiresValue: true },
{ id: "greater_than", label: "Greater Than", requiresValue: true },
{ id: "less_than", label: "Less Than", requiresValue: true },
{ id: "between", label: "Between", requiresValue: true },
{ id: "is_set", label: "Is Set", requiresValue: false },
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
],
valueType: "number",
},
{
id: "rating",
label: "Rating",
operators: [
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "not_equals", label: "Not Equals", requiresValue: true },
{ id: "greater_than", label: "Greater Than", requiresValue: true },
{ id: "less_than", label: "Less Than", requiresValue: true },
{ id: "is_set", label: "Is Set", requiresValue: false },
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
],
valueType: "number",
},
{
id: "date_added",
label: "Date Added",
operators: [
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "not_equals", label: "Not Equals", requiresValue: true },
{ id: "before", label: "Before", requiresValue: true },
{ id: "after", label: "After", requiresValue: true },
{ id: "between", label: "Between", requiresValue: true },
{ id: "last_x_days", label: "Last X Days", requiresValue: true },
],
valueType: "date",
},
{
id: "last_read",
label: "Last Read Date",
operators: [
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "before", label: "Before", requiresValue: true },
{ id: "after", label: "After", requiresValue: true },
{ id: "between", label: "Between", requiresValue: true },
{ id: "last_x_days", label: "Last X Days", requiresValue: true },
{ id: "is_set", label: "Is Set", requiresValue: false },
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
],
valueType: "date",
},
{
id: "publisher",
label: "Publisher",
operators: [
{ id: "contains", label: "Contains", requiresValue: true },
{ id: "equals", label: "Equals", requiresValue: true },
],
valueType: "text",
},
{
id: "language",
label: "Language",
operators: [
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "not_equals", label: "Not Equals", requiresValue: true },
{ id: "in", label: "In", requiresValue: true },
],
valueType: "select",
options: ["English", "Spanish", "French", "German", "Japanese", "Chinese", "Russian", "Other"],
},
{
id: "format",
label: "Format",
operators: [
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "in", label: "In", requiresValue: true },
],
valueType: "select",
options: ["Ebook", "Audiobook", "Comic", "Manga", "Magazine"],
},
{
id: "tags",
label: "Tags",
operators: [
{ id: "contains", label: "Contains", requiresValue: true },
{ id: "not_contains", label: "Does Not Contain", requiresValue: true },
{ id: "equals", label: "Equals", requiresValue: true },
],
valueType: "text",
},
{
id: "narrators",
label: "Narrators (Audiobooks)",
operators: [
{ id: "contains", label: "Contains", requiresValue: true },
{ id: "equals", label: "Equals", requiresValue: true },
{ id: "is_set", label: "Is Set", requiresValue: false },
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
],
valueType: "text",
},
];
let ruleCounter = 0;
// Books picked in the builder only ever carry identity fields; the API's
// BookInfo conflict/format/badge metadata is attached server-side later.
type SelectedBook = Pick<BookInfo, "media_item_id" | "title" | "author" | "cover_image_path">;
let selectedBooks: Map<string, SelectedBook> = new Map();
let customSectionTimeout: number | null = null;
function initCustomSectionBuilder(): void {
const addRuleBtn = document.getElementById("add-rule-btn");
const previewBtn = document.getElementById("preview-btn");
const searchBtn = document.getElementById("search-books-btn");
const bookSearchInput = document.getElementById("book-search");
const cancelBtn = document.getElementById("cancel-btn");
const form = document.getElementById("custom-section-form");
if (addRuleBtn) {
addRuleBtn.addEventListener("click", addFilterRule);
}
if (previewBtn) {
previewBtn.addEventListener("click", loadPreview);
}
if (searchBtn) {
searchBtn.addEventListener("click", searchBooks);
}
if (bookSearchInput) {
bookSearchInput.addEventListener("input", onBookSearchInput);
bookSearchInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
e.preventDefault();
searchBooks();
}
});
}
if (cancelBtn) {
cancelBtn.addEventListener("click", () => {
window.location.href = "/dashboard";
});
}
if (form) {
form.addEventListener("submit", saveCustomSection);
}
}
function addFilterRule(): void {
const container = document.getElementById("rules-container");
if (!container) return;
ruleCounter++;
const ruleId = `rule-${ruleCounter}`;
const ruleElement = document.createElement("div");
ruleElement.className = "rule-item p-3 rounded border";
ruleElement.dataset.ruleId = ruleId;
ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
ruleElement.innerHTML = `
<div class="flex items-center gap-2 mb-2">
<select class="field-select flex-1 px-3 py-1 rounded border"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
<option value="">Select field...</option>
${FILTER_FIELDS.map((field) => `<option value="${field.id}">${field.label}</option>`).join("")}
</select>
<button type="button" class="remove-rule-btn text-red-500 hover:text-red-700 px-2" data-rule-id="${ruleId}">
Remove
</button>
</div>
<div class="flex items-center gap-2">
<select class="operator-select flex-1 px-3 py-1 rounded border"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
disabled>
<option value="">Select field first...</option>
</select>
<input type="text" class="value-input flex-1 px-3 py-1 rounded border hidden"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
placeholder="Enter value...">
</div>
`;
container.appendChild(ruleElement);
const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement;
const removeBtn = ruleElement.querySelector(".remove-rule-btn") as HTMLButtonElement;
fieldSelect.addEventListener("change", () => onFieldChange(ruleElement));
removeBtn.addEventListener("click", () => removeFilterRule(ruleId));
}
function onFieldChange(ruleElement: HTMLElement): void {
const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement;
const operatorSelect = ruleElement.querySelector(".operator-select") as HTMLSelectElement;
const valueInput = ruleElement.querySelector(".value-input") as HTMLInputElement;
const fieldId = fieldSelect.value;
const field = FILTER_FIELDS.find((f) => f.id === fieldId);
operatorSelect.innerHTML = field
? field.operators.map((op) => `<option value="${op.id}">${op.label}</option>`).join("")
: '<option value="">Select field first...</option>';
operatorSelect.disabled = !field;
if (field && field.operators.some((op) => op.id === operatorSelect.value && op.requiresValue)) {
valueInput.classList.remove("hidden");
if (field.valueType === "select" && field.options) {
valueInput.type = "select";
} else if (field.valueType === "number") {
valueInput.type = "number";
valueInput.step = "0.01";
} else if (field.valueType === "date") {
valueInput.type = "date";
} else {
valueInput.type = "text";
}
} else {
valueInput.classList.add("hidden");
}
operatorSelect.addEventListener("change", () => {
const selectedOp = field?.operators.find((op) => op.id === operatorSelect.value);
if (selectedOp?.requiresValue) {
valueInput.classList.remove("hidden");
} else {
valueInput.classList.add("hidden");
}
});
}
function removeFilterRule(ruleId: string): void {
const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`);
if (ruleElement) {
ruleElement.remove();
}
}
function onBookSearchInput(): void {
if (customSectionTimeout) {
clearTimeout(customSectionTimeout);
}
customSectionTimeout = window.setTimeout(() => {
searchBooks();
}, 300);
}
async function searchBooks(): Promise<void> {
const searchInput = document.getElementById("book-search") as HTMLInputElement;
const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
const resultsContainer = document.getElementById("search-results") as HTMLElement;
const query = searchInput?.value.trim();
const libraryId = librarySelect?.value;
if (!query || !libraryId) {
if (resultsContainer) resultsContainer.classList.add("hidden");
return;
}
try {
const response = await fetch(
`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to search books");
}
const data = await response.json();
displaySearchResults(data.books || []);
} catch (error) {
console.error("Search books error:", error);
showToast("Failed to search books", "error");
}
}
function displaySearchResults(books: BookInfo[]): void {
const resultsContainer = document.getElementById("search-results") as HTMLElement;
if (!resultsContainer) return;
if (books.length === 0) {
resultsContainer.innerHTML =
'<p class="text-center" style="color: var(--text-secondary);">No books found</p>';
} else {
resultsContainer.innerHTML = books
.map(
(book) => `
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
data-book-id="${book.media_item_id}"
onclick="addBookToSelection('${book.media_item_id}', '${builderEscapeHtml(book.title)}', '${builderEscapeHtml(book.author)}')">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}"
alt="${builderEscapeHtml(book.title)}"
class="w-10 h-15 object-cover rounded">
<div class="flex-1">
<p class="text-sm font-medium" style="color: var(--text-primary);">${builderEscapeHtml(book.title)}</p>
<p class="text-xs" style="color: var(--text-secondary);">${builderEscapeHtml(book.author)}</p>
</div>
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
</div>
`,
)
.join("");
}
resultsContainer.classList.remove("hidden");
}
const addBookToSelection = (bookId: string, title: string, author: string): void => {
if (selectedBooks.has(bookId)) {
showToast("Book already selected", "error");
return;
}
selectedBooks.set(bookId, {
media_item_id: bookId,
title: title,
author: author,
cover_image_path: "",
});
updateSelectedBooksDisplay();
};
const removeBookFromSelection = (bookId: string): void => {
selectedBooks.delete(bookId);
updateSelectedBooksDisplay();
};
function updateSelectedBooksDisplay(): void {
const container = document.getElementById("selected-books") as HTMLElement;
if (!container) return;
if (selectedBooks.size === 0) {
container.innerHTML =
'<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>';
return;
}
container.innerHTML = Array.from(selectedBooks.values())
.map(
(book) => `
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
style="background-color: var(--accent);">
<span>${builderEscapeHtml(book.title)}</span>
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
class="hover:opacity-70">×</button>
</div>
`,
)
.join("");
}
async function loadPreview(): Promise<void> {
const previewContainer = document.getElementById("preview-container") as HTMLElement;
const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
const libraryId = librarySelect?.value;
if (!libraryId) {
showToast("Please select a library first", "error");
return;
}
const rules = gatherFilterRules();
const manualBookIds = Array.from(selectedBooks.keys());
previewContainer.innerHTML =
'<div class="text-center"><div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent rounded-full"></div></div>';
try {
const response = await apiPost("/collections/preview", {
library_id: libraryId,
rules: rules,
manual_book_ids: manualBookIds,
limit: 20,
});
if (response.ok) {
const data = await response.json();
displayPreview(data.items || []);
} else {
throw new Error("Failed to load preview");
}
} catch (error) {
console.error("Preview error:", error);
previewContainer.innerHTML = '<p class="text-center text-red-500">Failed to load preview</p>';
}
}
function gatherFilterRules(): FilterRule[] {
const container = document.getElementById("rules-container") as HTMLElement;
if (!container) return [];
const ruleElements = container.querySelectorAll(".rule-item");
const rules: FilterRule[] = [];
ruleElements.forEach((element, index) => {
const fieldSelect = element.querySelector(".field-select") as HTMLSelectElement;
const operatorSelect = element.querySelector(".operator-select") as HTMLSelectElement;
const valueInput = element.querySelector(".value-input") as HTMLInputElement;
if (fieldSelect.value && operatorSelect.value) {
rules.push({
id: `rule-${index}`,
field: fieldSelect.value,
operator: operatorSelect.value,
value: valueInput.value,
priority: index,
});
}
});
return rules;
}
function displayPreview(items: BookInfo[]): void {
const previewContainer = document.getElementById("preview-container") as HTMLElement;
if (!previewContainer) return;
if (items.length === 0) {
previewContainer.innerHTML =
'<p class="text-center" style="color: var(--text-secondary);">No items match your criteria</p>';
return;
}
previewContainer.innerHTML = `
<div class="flex gap-4 overflow-x-auto pb-4">
${items
.map(
(item) => `
<div class="flex-shrink-0 w-32">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}"
alt="${builderEscapeHtml(item.title)}"
class="w-full h-full object-cover">
</div>
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
${builderEscapeHtml(item.title)}
</h3>
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${builderEscapeHtml(item.author)}</p>` : ""}
</div>
`,
)
.join("")}
</div>
<p class="text-sm text-center mt-2" style="color: var(--text-secondary);">
${items.length} item${items.length !== 1 ? "s" : ""} will be shown
</p>
`;
}
async function saveCustomSection(event: Event): Promise<void> {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
const libraryId = formData.get("library_id") as string;
const name = formData.get("name") as string;
const icon = formData.get("icon") as string;
const description = formData.get("description") as string;
const matchType = (document.getElementById("match-type") as HTMLSelectElement).value;
if (!libraryId || !name) {
showToast("Please fill in required fields", "error");
return;
}
const rules = gatherFilterRules();
const manualBookIds = Array.from(selectedBooks.keys());
if (rules.length === 0 && manualBookIds.length === 0) {
showToast("Please add filter rules or select books", "error");
return;
}
try {
const response = await apiPost("/collections", {
library_id: libraryId,
name: name,
icon: icon,
description: description,
show_on_dashboard: true,
auto_assign_rules: JSON.stringify(rules),
manual_book_ids: manualBookIds,
match_type: matchType,
});
if (response.ok) {
showToast("Custom section created successfully", "success");
setTimeout(() => {
window.location.href = "/dashboard";
}, 1000);
} else {
throw new Error("Failed to save custom section");
}
} catch (error) {
console.error("Save custom section error:", error);
showToast("Failed to save custom section", "error");
}
}
function builderEscapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
initCustomSectionBuilder();
export { addBookToSelection, removeBookFromSelection };