initial search filter feature
This commit is contained in:
296
static/js/search.js
Normal file
296
static/js/search.js
Normal file
@ -0,0 +1,296 @@
|
||||
class SearchInterface {
|
||||
constructor() {
|
||||
this.searchIndex = [];
|
||||
this.fuse = null;
|
||||
this.filters = {
|
||||
tags: new Set(),
|
||||
narrators: new Set(),
|
||||
facilitators: new Set(),
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
useOrLogic: false
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const response = await fetch('/search.json');
|
||||
this.searchIndex = await response.json();
|
||||
|
||||
this.fuse = new Fuse(this.searchIndex, {
|
||||
keys: ['title', 'content', 'summary', 'tags', 'narrator', 'facilitator'],
|
||||
threshold: 0.3,
|
||||
ignoreLocation: true,
|
||||
useExtendedSearch: true
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
this.populateFilterOptions();
|
||||
this.initializeFromURL();
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Failed to initialize search');
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error, message = 'Search error') {
|
||||
console.error(message, error);
|
||||
const resultsDiv = document.querySelector('[data-results]');
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="text-red-500 text-center py-8">
|
||||
${message}. Please try again later.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const searchInput = document.querySelector('[data-search-input]');
|
||||
const filterToggle = document.querySelector('[data-filter-toggle]');
|
||||
const filterPanel = document.querySelector('[data-filter-panel]');
|
||||
const logicMode = document.querySelector('[data-logic-mode]');
|
||||
const dateInputs = document.querySelectorAll('[data-date-from], [data-date-to]');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input',
|
||||
this.debounce(() => this.performSearch(), 300)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterToggle && filterPanel) {
|
||||
filterToggle.addEventListener('click', () => {
|
||||
filterPanel.classList.toggle('active');
|
||||
});
|
||||
}
|
||||
|
||||
if (logicMode) {
|
||||
logicMode.addEventListener('change', () => {
|
||||
this.filters.useOrLogic = logicMode.checked;
|
||||
this.performSearch();
|
||||
});
|
||||
}
|
||||
|
||||
dateInputs.forEach(input => {
|
||||
input.addEventListener('change', () => this.performSearch());
|
||||
});
|
||||
}
|
||||
|
||||
performSearch() {
|
||||
const searchInput = document.querySelector('[data-search-input]');
|
||||
const dateFrom = document.querySelector('[data-date-from]');
|
||||
const dateTo = document.querySelector('[data-date-to]');
|
||||
const resultsDiv = document.querySelector('[data-results]');
|
||||
const countDiv = document.querySelector('[data-results-count]');
|
||||
|
||||
// Get current search state
|
||||
const query = searchInput?.value || '';
|
||||
this.filters.dateFrom = this.parseDate(dateFrom?.value);
|
||||
this.filters.dateTo = this.parseDate(dateTo?.value);
|
||||
|
||||
// Perform search
|
||||
let results = query ? this.fuse.search(query) : this.searchIndex;
|
||||
results = results.map(r => r.item || r); // Handle Fuse.js results format
|
||||
|
||||
// Apply filters
|
||||
results = results.filter(item => {
|
||||
const matchesTags = this.filters.tags.size === 0 || (item.tags &&
|
||||
(this.filters.useOrLogic
|
||||
? item.tags.some(tag => this.filters.tags.has(tag))
|
||||
: item.tags.every(tag => this.filters.tags.has(tag))));
|
||||
|
||||
const matchesNarrator = this.filters.narrators.size === 0 ||
|
||||
this.filters.narrators.has(item.narrator);
|
||||
|
||||
const matchesFacilitator = this.filters.facilitators.size === 0 ||
|
||||
this.filters.facilitators.has(item.facilitator);
|
||||
|
||||
const itemDate = new Date(item.date);
|
||||
const afterStartDate = !this.filters.dateFrom ||
|
||||
itemDate >= new Date(this.filters.dateFrom);
|
||||
const beforeEndDate = !this.filters.dateTo ||
|
||||
itemDate <= new Date(this.filters.dateTo);
|
||||
|
||||
return matchesTags && matchesNarrator && matchesFacilitator &&
|
||||
afterStartDate && beforeEndDate;
|
||||
});
|
||||
|
||||
// Update URL state
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('q', query);
|
||||
if (this.filters.dateFrom) params.set('from', this.filters.dateFrom);
|
||||
if (this.filters.dateTo) params.set('to', this.filters.dateTo);
|
||||
if (this.filters.useOrLogic) params.set('logic', 'or');
|
||||
if (this.filters.tags.size) params.set('tags', Array.from(this.filters.tags).join(','));
|
||||
if (this.filters.narrators.size) params.set('narrators', Array.from(this.filters.narrators).join(','));
|
||||
if (this.filters.facilitators.size) params.set('facilitators', Array.from(this.filters.facilitators).join(','));
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
|
||||
// Render results
|
||||
if (resultsDiv) {
|
||||
if (results.length === 0) {
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="text-gray-500 text-center py-8">
|
||||
No results found. Try adjusting your search or filters.
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultsDiv.innerHTML = results.map(item => `
|
||||
<article class="border-b border-gray-200 pb-4">
|
||||
<h3 class="text-xl font-semibold">
|
||||
<a href="${item.url}" class="hover:text-blue-600">${item.title}</a>
|
||||
</h3>
|
||||
<div class="flex gap-2 text-sm text-gray-600 mt-1">
|
||||
<span>${item.narrator}</span>
|
||||
<span>•</span>
|
||||
<span>${new Date(item.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
${item.tags ? `
|
||||
<div class="flex gap-2 mt-2">
|
||||
${item.tags.map(tag => `
|
||||
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
|
||||
${tag}
|
||||
</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<p class="mt-2 text-gray-600">${item.summary}</p>
|
||||
</article>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Update count
|
||||
if (countDiv) {
|
||||
countDiv.textContent = `${results.length} result${results.length === 1 ? '' : 's'} found`;
|
||||
}
|
||||
}
|
||||
|
||||
populateFilterOptions() {
|
||||
const tagsContainer = document.querySelector('[data-filter-tags]');
|
||||
const narratorsContainer = document.querySelector('[data-filter-narrators]');
|
||||
const facilitatorsContainer = document.querySelector('[data-filter-facilitators]');
|
||||
|
||||
// Get unique values
|
||||
const tags = new Set(this.searchIndex.flatMap(item => item.tags || []));
|
||||
const narrators = new Set(this.searchIndex.map(item => item.narrator).filter(Boolean));
|
||||
const facilitators = new Set(this.searchIndex.map(item => item.facilitator).filter(Boolean));
|
||||
|
||||
// Helper to create filter options
|
||||
const createFilterOption = (value, container, filterSet) => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'flex items-center gap-2 text-sm';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = value;
|
||||
checkbox.checked = filterSet.has(value);
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) {
|
||||
filterSet.add(value);
|
||||
} else {
|
||||
filterSet.delete(value);
|
||||
}
|
||||
this.performSearch();
|
||||
});
|
||||
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(document.createTextNode(value));
|
||||
container?.appendChild(label);
|
||||
};
|
||||
|
||||
// Populate filters
|
||||
if (tagsContainer) {
|
||||
tagsContainer.innerHTML = '';
|
||||
Array.from(tags).sort().forEach(tag =>
|
||||
createFilterOption(tag, tagsContainer, this.filters.tags)
|
||||
);
|
||||
}
|
||||
|
||||
if (narratorsContainer) {
|
||||
narratorsContainer.innerHTML = '';
|
||||
Array.from(narrators).sort().forEach(narrator =>
|
||||
createFilterOption(narrator, narratorsContainer, this.filters.narrators)
|
||||
);
|
||||
}
|
||||
|
||||
if (facilitatorsContainer) {
|
||||
facilitatorsContainer.innerHTML = '';
|
||||
Array.from(facilitators).sort().forEach(facilitator =>
|
||||
createFilterOption(facilitator, facilitatorsContainer, this.filters.facilitators)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
initializeFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const searchInput = document.querySelector('[data-search-input]');
|
||||
const dateFrom = document.querySelector('[data-date-from]');
|
||||
const dateTo = document.querySelector('[data-date-to]');
|
||||
const logicMode = document.querySelector('[data-logic-mode]');
|
||||
|
||||
// Restore search query
|
||||
if (searchInput && params.has('q')) {
|
||||
searchInput.value = params.get('q');
|
||||
}
|
||||
|
||||
// Restore date filters
|
||||
if (dateFrom && params.has('from')) {
|
||||
dateFrom.value = params.get('from');
|
||||
this.filters.dateFrom = params.get('from');
|
||||
}
|
||||
if (dateTo && params.has('to')) {
|
||||
dateTo.value = params.get('to');
|
||||
this.filters.dateTo = params.get('to');
|
||||
}
|
||||
|
||||
// Restore logic mode
|
||||
if (logicMode) {
|
||||
this.filters.useOrLogic = params.get('logic') === 'or';
|
||||
logicMode.checked = this.filters.useOrLogic;
|
||||
}
|
||||
|
||||
// Restore filter selections
|
||||
if (params.has('tags')) {
|
||||
params.get('tags').split(',').forEach(tag => this.filters.tags.add(tag));
|
||||
}
|
||||
if (params.has('narrators')) {
|
||||
params.get('narrators').split(',').forEach(narrator => this.filters.narrators.add(narrator));
|
||||
}
|
||||
if (params.has('facilitators')) {
|
||||
params.get('facilitators').split(',').forEach(facilitator => this.filters.facilitators.add(facilitator));
|
||||
}
|
||||
|
||||
// Perform initial search if we have any parameters
|
||||
if ([...params.keys()].length > 0) {
|
||||
this.performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
parseDate(dateString) {
|
||||
try {
|
||||
return dateString ? new Date(dateString) : null;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Invalid date format');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize search when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => new SearchInterface());
|
||||
} else {
|
||||
new SearchInterface();
|
||||
}
|
Reference in New Issue
Block a user