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; let selectedBooks: Map = 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 = `
`; 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) => ``) .join("") : ''; 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 { 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 = '

No books found

'; } else { resultsContainer.innerHTML = books .map( (book) => `
${builderEscapeHtml(book.title)}

${builderEscapeHtml(book.title)}

${builderEscapeHtml(book.author)}

`, ) .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 = '

No books selected

'; return; } container.innerHTML = Array.from(selectedBooks.values()) .map( (book) => `
${builderEscapeHtml(book.title)}
`, ) .join(""); } async function loadPreview(): Promise { 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 = '
'; 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 = '

Failed to load preview

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

No items match your criteria

'; return; } previewContainer.innerHTML = `
${items .map( (item) => `
${builderEscapeHtml(item.title)}

${builderEscapeHtml(item.title)}

${item.author ? `

${builderEscapeHtml(item.author)}

` : ""}
`, ) .join("")}

${items.length} item${items.length !== 1 ? "s" : ""} will be shown

`; } async function saveCustomSection(event: Event): Promise { 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 };