Files
thrillwiki_django_no_react/staticfiles/parks/js/search.js
pacnpal 652ea149bd Refactor park filtering system and templates
- Updated the filtered_list.html template to extend from base/base.html and improved layout and styling.
- Removed the park_list.html template as its functionality is now integrated into the filtered list.
- Added a new migration to create indexes for improved filtering performance on the parks model.
- Merged migrations to maintain a clean migration history.
- Implemented a ParkFilterService to handle complex filtering logic, aggregations, and caching for park filters.
- Enhanced filter suggestions and popular filters retrieval methods.
- Improved the overall structure and efficiency of the filtering system.
2025-08-20 21:20:10 -04:00

550 lines
18 KiB
JavaScript

/**
* Enhanced Parks Search and Filter Management
* Provides comprehensive UX improvements for the parks listing page
*/
class ParkSearchManager {
constructor() {
this.debounceTimers = new Map();
this.filterState = new Map();
this.requestCount = 0;
this.lastRequestTime = 0;
this.init();
}
init() {
this.setupEventListeners();
this.initializeLazyLoading();
this.setupKeyboardNavigation();
this.restoreFilterState();
this.setupPerformanceOptimizations();
}
setupEventListeners() {
// Enhanced HTMX request handling
document.addEventListener('htmx:configRequest', (evt) => this.handleConfigRequest(evt));
document.addEventListener('htmx:beforeRequest', (evt) => this.handleBeforeRequest(evt));
document.addEventListener('htmx:afterRequest', (evt) => this.handleAfterRequest(evt));
document.addEventListener('htmx:responseError', (evt) => this.handleResponseError(evt));
document.addEventListener('htmx:historyRestore', (evt) => this.handleHistoryRestore(evt));
document.addEventListener('htmx:afterSwap', (evt) => this.handleAfterSwap(evt));
// Enhanced form interactions
document.addEventListener('input', (evt) => this.handleInput(evt));
document.addEventListener('change', (evt) => this.handleChange(evt));
document.addEventListener('focus', (evt) => this.handleFocus(evt));
document.addEventListener('blur', (evt) => this.handleBlur(evt));
// Search suggestions
document.addEventListener('keydown', (evt) => this.handleKeydown(evt));
// Window events
window.addEventListener('beforeunload', () => this.saveFilterState());
window.addEventListener('resize', this.debounce(() => this.handleResize(), 250));
}
handleConfigRequest(evt) {
// Preserve view mode
const parkResults = document.getElementById('park-results');
if (parkResults) {
const viewMode = parkResults.getAttribute('data-view-mode');
if (viewMode) {
evt.detail.parameters['view_mode'] = viewMode;
}
}
// Preserve search terms
const searchInput = document.querySelector('input[name="search"]');
if (searchInput && searchInput.value) {
evt.detail.parameters['search'] = searchInput.value;
}
// Add request tracking
evt.detail.parameters['_req_id'] = ++this.requestCount;
this.lastRequestTime = Date.now();
}
handleBeforeRequest(evt) {
const target = evt.detail.target;
if (target) {
target.classList.add('htmx-requesting');
this.showLoadingIndicator(target);
}
// Disable form elements during request
this.toggleFormElements(false);
// Track request analytics
this.trackFilterUsage(evt);
}
handleAfterRequest(evt) {
const target = evt.detail.target;
if (target) {
target.classList.remove('htmx-requesting');
this.hideLoadingIndicator(target);
}
// Re-enable form elements
this.toggleFormElements(true);
// Handle response timing
const responseTime = Date.now() - this.lastRequestTime;
if (responseTime > 3000) {
this.showPerformanceWarning();
}
}
handleResponseError(evt) {
this.hideLoadingIndicator(evt.detail.target);
this.toggleFormElements(true);
this.showErrorMessage('Failed to load results. Please try again.');
}
handleAfterSwap(evt) {
if (evt.detail.target.id === 'results-container') {
this.initializeLazyLoading(evt.detail.target);
this.updateResultsInfo(evt.detail.target);
this.animateResults(evt.detail.target);
}
}
handleHistoryRestore(evt) {
const parkResults = document.getElementById('park-results');
if (parkResults && evt.detail.path) {
const url = new URL(evt.detail.path, window.location.origin);
const viewMode = url.searchParams.get('view_mode');
if (viewMode) {
parkResults.setAttribute('data-view-mode', viewMode);
}
}
// Restore filter state from URL
this.restoreFiltersFromURL(evt.detail.path);
}
handleInput(evt) {
if (evt.target.type === 'search' || evt.target.type === 'text') {
this.debounceInput(evt.target);
}
}
handleChange(evt) {
if (evt.target.closest('#filter-form')) {
this.updateFilterState();
this.saveFilterState();
}
}
handleFocus(evt) {
if (evt.target.type === 'search') {
this.highlightSearchSuggestions(evt.target);
}
}
handleBlur(evt) {
if (evt.target.type === 'search') {
// Delay hiding suggestions to allow for clicks
setTimeout(() => this.hideSearchSuggestions(), 150);
}
}
handleKeydown(evt) {
if (evt.target.type === 'search') {
this.handleSearchKeyboard(evt);
}
}
handleResize() {
// Responsive adjustments
this.adjustLayoutForViewport();
}
debounceInput(input) {
const key = input.name || input.id;
if (this.debounceTimers.has(key)) {
clearTimeout(this.debounceTimers.get(key));
}
const delay = input.type === 'search' ? 300 : 500;
const timer = setTimeout(() => {
if (input.form) {
htmx.trigger(input.form, 'change');
}
this.debounceTimers.delete(key);
}, delay);
this.debounceTimers.set(key, timer);
}
handleSearchKeyboard(evt) {
const suggestions = document.getElementById('search-results');
if (!suggestions) return;
const items = suggestions.querySelectorAll('[role="option"]');
let activeIndex = Array.from(items).findIndex(item =>
item.classList.contains('active') || item.classList.contains('highlighted')
);
switch (evt.key) {
case 'ArrowDown':
evt.preventDefault();
activeIndex = Math.min(activeIndex + 1, items.length - 1);
this.highlightSuggestion(items, activeIndex);
break;
case 'ArrowUp':
evt.preventDefault();
activeIndex = Math.max(activeIndex - 1, -1);
this.highlightSuggestion(items, activeIndex);
break;
case 'Enter':
if (activeIndex >= 0 && items[activeIndex]) {
evt.preventDefault();
items[activeIndex].click();
}
break;
case 'Escape':
this.hideSearchSuggestions();
evt.target.blur();
break;
}
}
highlightSuggestion(items, activeIndex) {
items.forEach((item, index) => {
item.classList.toggle('active', index === activeIndex);
item.classList.toggle('highlighted', index === activeIndex);
});
}
highlightSearchSuggestions(input) {
const suggestions = document.getElementById('search-results');
if (suggestions && input.value) {
suggestions.style.display = 'block';
}
}
hideSearchSuggestions() {
const suggestions = document.getElementById('search-results');
if (suggestions) {
suggestions.style.display = 'none';
}
}
initializeLazyLoading(container = document) {
if (!('IntersectionObserver' in window)) return;
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
img.classList.add('loaded');
imageObserver.unobserve(img);
}
}
});
}, {
threshold: 0.1,
rootMargin: '50px'
});
container.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
setupKeyboardNavigation() {
// Tab navigation for filter cards
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Tab' && evt.target.closest('.park-card')) {
this.handleCardNavigation(evt);
}
});
}
setupPerformanceOptimizations() {
// Prefetch next page if pagination exists
this.setupPrefetching();
// Optimize scroll performance
this.setupScrollOptimization();
}
setupPrefetching() {
const nextPageLink = document.querySelector('a[rel="next"]');
if (nextPageLink && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.prefetchPage(nextPageLink.href);
observer.unobserve(entry.target);
}
});
});
const trigger = document.querySelector('.pagination');
if (trigger) {
observer.observe(trigger);
}
}
}
setupScrollOptimization() {
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
this.handleScroll();
ticking = false;
});
ticking = true;
}
});
}
handleScroll() {
// Show/hide back to top button
const backToTop = document.getElementById('back-to-top');
if (backToTop) {
backToTop.style.display = window.scrollY > 500 ? 'block' : 'none';
}
}
prefetchPage(url) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
}
showLoadingIndicator(target) {
// Add subtle loading animation
target.style.transition = 'opacity 0.3s ease-in-out';
target.style.opacity = '0.7';
}
hideLoadingIndicator(target) {
target.style.opacity = '1';
}
toggleFormElements(enabled) {
const form = document.getElementById('filter-form');
if (form) {
const elements = form.querySelectorAll('input, select, button');
elements.forEach(el => {
el.disabled = !enabled;
});
}
}
updateFilterState() {
const form = document.getElementById('filter-form');
if (!form) return;
const formData = new FormData(form);
this.filterState.clear();
for (const [key, value] of formData.entries()) {
if (value && value !== '') {
this.filterState.set(key, value);
}
}
}
saveFilterState() {
try {
const state = Object.fromEntries(this.filterState);
localStorage.setItem('parkFilters', JSON.stringify(state));
} catch (e) {
console.warn('Failed to save filter state:', e);
}
}
restoreFilterState() {
try {
const saved = localStorage.getItem('parkFilters');
if (saved) {
const state = JSON.parse(saved);
this.applyFilterState(state);
}
} catch (e) {
console.warn('Failed to restore filter state:', e);
}
}
restoreFiltersFromURL(path) {
const url = new URL(path, window.location.origin);
const params = new URLSearchParams(url.search);
const form = document.getElementById('filter-form');
if (!form) return;
// Clear existing values
form.reset();
// Apply URL parameters
for (const [key, value] of params.entries()) {
const input = form.querySelector(`[name="${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value === 'on' || value === 'true';
} else {
input.value = value;
}
}
}
}
applyFilterState(state) {
const form = document.getElementById('filter-form');
if (!form) return;
Object.entries(state).forEach(([key, value]) => {
const input = form.querySelector(`[name="${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value === 'on' || value === 'true';
} else {
input.value = value;
}
}
});
}
updateResultsInfo(container) {
// Update any result count displays
const countElements = container.querySelectorAll('[data-result-count]');
countElements.forEach(el => {
const count = container.querySelectorAll('.park-card').length;
el.textContent = count;
});
}
animateResults(container) {
// Subtle animation for new results
const cards = container.querySelectorAll('.park-card');
cards.forEach((card, index) => {
card.style.opacity = '0';
card.style.transform = 'translateY(20px)';
setTimeout(() => {
card.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
card.style.opacity = '1';
card.style.transform = 'translateY(0)';
}, index * 50);
});
}
adjustLayoutForViewport() {
const viewport = window.innerWidth;
// Adjust grid columns based on viewport
const grid = document.querySelector('.park-card-grid');
if (grid) {
if (viewport < 768) {
grid.style.gridTemplateColumns = '1fr';
} else if (viewport < 1024) {
grid.style.gridTemplateColumns = 'repeat(2, 1fr)';
} else {
grid.style.gridTemplateColumns = 'repeat(3, 1fr)';
}
}
}
trackFilterUsage(evt) {
// Track which filters are being used for analytics
if (window.gtag) {
const formData = new FormData(evt.detail.elt);
const activeFilters = [];
for (const [key, value] of formData.entries()) {
if (value && value !== '' && key !== 'csrfmiddlewaretoken') {
activeFilters.push(key);
}
}
window.gtag('event', 'filter_usage', {
'filters_used': activeFilters.join(','),
'filter_count': activeFilters.length
});
}
}
showPerformanceWarning() {
// Show a subtle warning for slow responses
const warning = document.createElement('div');
warning.className = 'fixed top-4 right-4 bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded z-50';
warning.innerHTML = `
<span class="block sm:inline">Search is taking longer than expected...</span>
<span class="absolute top-0 bottom-0 right-0 px-4 py-3 cursor-pointer" onclick="this.parentElement.remove()">
<svg class="fill-current h-6 w-6 text-yellow-500" role="button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"/>
</svg>
</span>
`;
document.body.appendChild(warning);
setTimeout(() => {
if (warning.parentElement) {
warning.remove();
}
}, 5000);
}
showErrorMessage(message) {
// Show error message with retry option
const errorDiv = document.createElement('div');
errorDiv.className = 'fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50';
errorDiv.innerHTML = `
<span class="block sm:inline">${message}</span>
<button class="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded text-sm" onclick="location.reload()">
Retry
</button>
<span class="absolute top-0 bottom-0 right-0 px-4 py-3 cursor-pointer" onclick="this.parentElement.remove()">
<svg class="fill-current h-6 w-6 text-red-500" role="button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"/>
</svg>
</span>
`;
document.body.appendChild(errorDiv);
setTimeout(() => {
if (errorDiv.parentElement) {
errorDiv.remove();
}
}, 10000);
}
// Utility method for debouncing
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
// Initialize the enhanced search manager
document.addEventListener('DOMContentLoaded', () => {
window.parkSearchManager = new ParkSearchManager();
});
// Export for potential module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = ParkSearchManager;
}