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.
570 lines
21 KiB
TypeScript
570 lines
21 KiB
TypeScript
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);
|