import type { BookInfo } from './types/api'; 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: '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 searchTimeout: 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 (searchTimeout) { clearTimeout(searchTimeout); } searchTimeout = 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); (window as any).showToast?.error('Failed to search books'); } } 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 => `
${escapeHtml(book.title)}

${escapeHtml(book.title)}

${escapeHtml(book.author)}

`).join(''); } resultsContainer.classList.remove('hidden'); } (window as any).addBookToSelection = function(bookId: string, title: string, author: string): void { if (selectedBooks.has(bookId)) { (window as any).showToast?.warning('Book already selected'); return; } selectedBooks.set(bookId, { media_item_id: bookId, title: title, author: author, cover_image_path: '', }); updateSelectedBooksDisplay(); }; (window as any).removeBookFromSelection = function(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 => `
${escapeHtml(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) { (window as any).showToast?.error('Please select a library first'); return; } const rules = gatherFilterRules(); const manualBookIds = Array.from(selectedBooks.keys()); previewContainer.innerHTML = '
'; try { const response = await (window as any).api.post('/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 => `
${escapeHtml(item.title)}

${escapeHtml(item.title)}

${item.author ? `

${escapeHtml(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) { (window as any).showToast?.error('Please fill in required fields'); return; } const rules = gatherFilterRules(); const manualBookIds = Array.from(selectedBooks.keys()); if (rules.length === 0 && manualBookIds.length === 0) { (window as any).showToast?.error('Please add filter rules or select books'); return; } try { const response = await (window as any).api.post('/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) { (window as any).showToast?.success('Custom section created successfully'); setTimeout(() => { window.location.href = '/dashboard'; }, 1000); } else { throw new Error('Failed to save custom section'); } } catch (error) { console.error('Save custom section error:', error); (window as any).showToast?.error('Failed to save custom section'); } } function escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } document.addEventListener('DOMContentLoaded', initCustomSectionBuilder);