feat(dashboard): implement Phase 10.5 Custom Section Builder
Phase 10.5.1: Add /custom-section frontend route - Added route handler in internal/router/frontend.go - Fetches user libraries and renders custom section builder template Phase 10.5.2: Create custom section builder template - Created templates/custom_section.templ with full UI - Includes section details form, filter rules builder, manual book selection - Live preview functionality with preview container - Form actions for save/cancel Phase 10.5.3: Create custom-section-builder TypeScript - Created web/src/custom-section-builder.ts with 13+ filter fields - Filter fields: title, author, genre, series, progress, rating, date_added, last_read, publisher, language, format, tags, narrators - Procedural/imperative style (no OOP) as per guidelines - Rule builder with AND/OR logic support - Book search and multi-select functionality - Live preview via /api/collections/preview endpoint - Form validation and submission to /api/collections Phase 10.5.4: Build TypeScript modules - Compiled custom-section-builder.ts to web/static/custom-section-builder.js - Verified successful compilation with no errors - All existing TypeScript modules continue to compile Phase 10.5.5: Add Bruno tests for custom section creation - create-custom-section-rules.bru: Test creating section with filter rules - create-custom-section-manual.bru: Test creating section with manual book selection - create-custom-section-missing-fields.bru: Test error handling for missing required fields Phase 10.6: Build Verification - ✅ TypeScript modules compile successfully - ✅ Templates generate successfully - ✅ Go build succeeds with no compilation errors - ✅ All build artifacts verified (dashboard.js, custom-section-builder.js, dashboard_templ.go, custom_section_templ.go) This completes the Custom Section Builder feature, allowing users to create personalized dashboard sections with flexible filter rules or manual book selection.
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
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<string, BookInfo> = 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 = `
|
||||
<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 (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
searchTimeout = 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);
|
||||
(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 = '<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}', '${escapeHtml(book.title)}', '${escapeHtml(book.author)}')">
|
||||
<img src="${book.cover_image_path || '/static/placeholder-book.svg'}"
|
||||
alt="${escapeHtml(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);">${escapeHtml(book.title)}</p>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">${escapeHtml(book.author)}</p>
|
||||
</div>
|
||||
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
|
||||
</div>
|
||||
`).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 = '<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>${escapeHtml(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) {
|
||||
(window as any).showToast?.error('Please select a library first');
|
||||
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 (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 = '<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="${escapeHtml(item.title)}"
|
||||
class="w-full h-full object-cover">
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
|
||||
${escapeHtml(item.title)}
|
||||
</h3>
|
||||
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${escapeHtml(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) {
|
||||
(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);
|
||||
Reference in New Issue
Block a user