mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 03:51:09 -05:00
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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/* Loading states */
|
||||
/* Enhanced Loading states */
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -10,23 +10,140 @@
|
||||
transition: opacity 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Loading pulse animation */
|
||||
@keyframes loading-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.htmx-request {
|
||||
animation: loading-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Results container transitions */
|
||||
#park-results {
|
||||
transition: opacity 200ms ease-in-out;
|
||||
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.htmx-request #park-results {
|
||||
opacity: 0.7;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.htmx-settling #park-results {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Filter UI Enhancements */
|
||||
.quick-filter-btn {
|
||||
@apply inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ease-in-out;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
|
||||
@apply transform hover:scale-105 active:scale-95;
|
||||
@apply border border-transparent;
|
||||
}
|
||||
|
||||
.quick-filter-btn:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
@apply text-xs opacity-75 ml-1;
|
||||
}
|
||||
|
||||
/* Filter Chips Styling */
|
||||
.filter-chip {
|
||||
@apply inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800;
|
||||
@apply dark:bg-blue-800 dark:text-blue-100 transition-all duration-200;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
@apply bg-blue-200 dark:bg-blue-700;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.filter-chip .remove-btn {
|
||||
@apply ml-2 inline-flex items-center justify-center w-4 h-4;
|
||||
@apply text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100;
|
||||
@apply focus:outline-none transition-colors duration-150;
|
||||
}
|
||||
|
||||
.filter-chip .remove-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Search Input */
|
||||
.search-input {
|
||||
@apply transition-all duration-200 ease-in-out;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
@apply ring-2 ring-blue-500 border-blue-500;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Enhanced Form Controls */
|
||||
.filter-field select,
|
||||
.filter-field input[type="text"],
|
||||
.filter-field input[type="number"],
|
||||
.filter-field input[type="search"],
|
||||
.form-field-wrapper input,
|
||||
.form-field-wrapper select {
|
||||
@apply transition-all duration-200 ease-in-out;
|
||||
@apply border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white;
|
||||
@apply focus:border-blue-500 focus:ring-blue-500;
|
||||
@apply rounded-md shadow-sm;
|
||||
}
|
||||
|
||||
.filter-field select:focus,
|
||||
.filter-field input:focus,
|
||||
.form-field-wrapper input:focus,
|
||||
.form-field-wrapper select:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.filter-field input[type="checkbox"],
|
||||
.form-field-wrapper input[type="checkbox"] {
|
||||
@apply rounded transition-colors duration-200;
|
||||
@apply text-blue-600 focus:ring-blue-500;
|
||||
@apply border-gray-300 dark:border-gray-600;
|
||||
}
|
||||
|
||||
/* Enhanced Status Indicators */
|
||||
.status-indicator {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.status-indicator.filtered {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.status-indicator.loading {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Grid/List transitions */
|
||||
.park-card {
|
||||
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
@@ -36,8 +153,8 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
.park-card[data-view-mode="grid"]:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* List view styles */
|
||||
@@ -48,12 +165,14 @@
|
||||
}
|
||||
.park-card[data-view-mode="list"]:hover {
|
||||
background-color: #f9fafb;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Image containers */
|
||||
.park-card .image-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.park-card[data-view-mode="grid"] .image-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
@@ -73,23 +192,26 @@
|
||||
min-width: 0; /* Enables text truncation in flex child */
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
/* Enhanced Status badges */
|
||||
.park-card .status-badge {
|
||||
transition: all 150ms ease-in-out;
|
||||
transition: all 200ms ease-in-out;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.park-card:hover .status-badge {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.park-card img {
|
||||
transition: transform 200ms ease-in-out;
|
||||
transition: transform 300ms ease-in-out;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.park-card:hover img {
|
||||
transform: scale(1.05);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* Placeholders for missing images */
|
||||
@@ -97,6 +219,7 @@
|
||||
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
@@ -105,7 +228,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
/* Enhanced No Results State */
|
||||
.no-results {
|
||||
@apply text-center py-12;
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.no-results-icon {
|
||||
@apply mx-auto w-24 h-24 text-gray-400 dark:text-gray-500 mb-6;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* Enhanced Buttons */
|
||||
.btn-enhanced {
|
||||
@apply transition-all duration-200 ease-in-out;
|
||||
@apply transform hover:scale-105 active:scale-95;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-enhanced:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Tooltip Styles */
|
||||
.tooltip {
|
||||
@apply absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 rounded shadow-lg;
|
||||
@apply dark:bg-gray-700 dark:text-gray-200;
|
||||
animation: tooltipFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tooltipFadeIn {
|
||||
from { opacity: 0; transform: scale(0.8); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Enhanced Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.park-card {
|
||||
background-color: #1f2937;
|
||||
@@ -128,7 +301,111 @@
|
||||
background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply dark:bg-gray-700 dark:border-gray-600 dark:text-white;
|
||||
}
|
||||
|
||||
.quick-filter-btn:not(.active) {
|
||||
@apply dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600;
|
||||
}
|
||||
|
||||
/* Enhanced filter panel styling */
|
||||
.filter-container {
|
||||
@apply dark:text-gray-200;
|
||||
}
|
||||
|
||||
.filter-field label {
|
||||
@apply dark:text-gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional enhancements for better visual hierarchy */
|
||||
.filter-container h3 {
|
||||
@apply font-semibold tracking-wide;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.filter-field label {
|
||||
@apply font-medium text-sm;
|
||||
}
|
||||
|
||||
/* Status badge improvements */
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Loading state improvements */
|
||||
.htmx-request .filter-container {
|
||||
@apply pointer-events-none;
|
||||
}
|
||||
|
||||
.htmx-request .quick-filter-btn {
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
/* Mobile Responsive Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
.quick-filter-btn {
|
||||
@apply text-xs px-2 py-1;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
@apply text-xs px-2 py-1;
|
||||
}
|
||||
|
||||
.park-card[data-view-mode="grid"]:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.park-card[data-view-mode="list"]:hover {
|
||||
background-color: #374151;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility Enhancements */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.park-card,
|
||||
.quick-filter-btn,
|
||||
.filter-chip,
|
||||
.btn-enhanced {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.park-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.no-results-icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus States for Keyboard Navigation */
|
||||
.park-card:focus-within {
|
||||
@apply ring-2 ring-blue-500 ring-offset-2;
|
||||
}
|
||||
|
||||
.quick-filter-btn:focus,
|
||||
.filter-chip .remove-btn:focus {
|
||||
@apply ring-2 ring-blue-500 ring-offset-2;
|
||||
}
|
||||
|
||||
/* High Contrast Mode Support */
|
||||
@media (prefers-contrast: high) {
|
||||
.park-card {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.quick-filter-btn {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,550 @@
|
||||
// Handle view mode persistence across HTMX requests
|
||||
document.addEventListener('htmx:configRequest', function(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;
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve search terms
|
||||
const searchInput = document.getElementById('search');
|
||||
if (searchInput && searchInput.value) {
|
||||
evt.detail.parameters['search'] = searchInput.value;
|
||||
|
||||
handleResponseError(evt) {
|
||||
this.hideLoadingIndicator(evt.detail.target);
|
||||
this.toggleFormElements(true);
|
||||
this.showErrorMessage('Failed to load results. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle loading states
|
||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.add('htmx-requesting');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.remove('htmx-requesting');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle history navigation
|
||||
document.addEventListener('htmx:historyRestore', function(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);
|
||||
|
||||
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;
|
||||
|
||||
// Initialize lazy loading for images
|
||||
function initializeLazyLoading(container) {
|
||||
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'
|
||||
});
|
||||
|
||||
const imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.removeAttribute('data-src');
|
||||
imageObserver.unobserve(img);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('img[data-src]').forEach(img => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
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 lazy loading after HTMX content swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
initializeLazyLoading(evt.detail.target);
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user