mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 05:11:09 -05:00
Refactor ride filters and forms to use AlpineJS for state management and HTMX for AJAX interactions
- Enhanced filter sidebar with AlpineJS for collapsible sections and localStorage persistence. - Removed custom JavaScript in favor of AlpineJS for managing filter states and interactions. - Updated ride form to utilize AlpineJS for handling manufacturer, designer, and ride model selections. - Simplified search script to leverage AlpineJS for managing search input and suggestions. - Improved error handling for HTMX requests with minimal JavaScript. - Refactored ride form data handling to encapsulate logic within an AlpineJS component.
This commit is contained in:
@@ -1,7 +1,45 @@
|
||||
{% load static %}
|
||||
|
||||
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
|
||||
<!-- Advanced Ride Filters Sidebar -->
|
||||
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto">
|
||||
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto"
|
||||
x-data="{
|
||||
sections: {
|
||||
'search-section': true,
|
||||
'basic-section': true,
|
||||
'date-section': false,
|
||||
'height-section': false,
|
||||
'performance-section': false,
|
||||
'relationships-section': false,
|
||||
'coaster-section': false,
|
||||
'sorting-section': false
|
||||
},
|
||||
|
||||
init() {
|
||||
// Restore section states from localStorage using AlpineJS patterns
|
||||
Object.keys(this.sections).forEach(sectionId => {
|
||||
const state = localStorage.getItem('filter-' + sectionId);
|
||||
if (state !== null) {
|
||||
this.sections[sectionId] = state === 'open';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
toggleSection(sectionId) {
|
||||
this.sections[sectionId] = !this.sections[sectionId];
|
||||
localStorage.setItem('filter-' + sectionId, this.sections[sectionId] ? 'open' : 'closed');
|
||||
},
|
||||
|
||||
removeFilter(category, filterName) {
|
||||
// Use HTMX to remove filter
|
||||
htmx.ajax('POST', '/rides/remove-filter/', {
|
||||
values: { category: category, filter: filterName },
|
||||
target: '#filter-results',
|
||||
swap: 'outerHTML'
|
||||
});
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Filter Header -->
|
||||
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4 z-10">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -42,7 +80,7 @@
|
||||
{{ filter_name }}: {{ filter_value }}
|
||||
<button type="button"
|
||||
class="ml-1 h-3 w-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
onclick="removeFilter('{{ category }}', '{{ filter_name }}')">
|
||||
@click="removeFilter('{{ category }}', '{{ filter_name }}')">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
@@ -67,16 +105,17 @@
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="search-section">
|
||||
@click="toggleSection('search-section')">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-search mr-2 text-gray-500"></i>
|
||||
Search
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||
:class="{ 'rotate-180': sections['search-section'] }"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="search-section" class="filter-content p-4 space-y-3">
|
||||
<div id="search-section" class="filter-content p-4 space-y-3" x-show="sections['search-section']" x-transition>
|
||||
{{ filter_form.search_text.label_tag }}
|
||||
{{ filter_form.search_text }}
|
||||
|
||||
@@ -93,16 +132,17 @@
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="basic-section">
|
||||
@click="toggleSection('basic-section')">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2 text-gray-500"></i>
|
||||
Basic Info
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||
:class="{ 'rotate-180': sections['basic-section'] }"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="basic-section" class="filter-content p-4 space-y-4">
|
||||
<div id="basic-section" class="filter-content p-4 space-y-4" x-show="sections['basic-section']" x-transition>
|
||||
<!-- Categories -->
|
||||
<div>
|
||||
{{ filter_form.categories.label_tag }}
|
||||
@@ -127,16 +167,17 @@
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="date-section">
|
||||
@click="toggleSection('date-section')">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-calendar mr-2 text-gray-500"></i>
|
||||
Date Ranges
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||
:class="{ 'rotate-180': sections['date-section'] }"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="date-section" class="filter-content p-4 space-y-4">
|
||||
<div id="date-section" class="filter-content p-4 space-y-4" x-show="sections['date-section']" x-transition>
|
||||
<!-- Opening Date Range -->
|
||||
<div>
|
||||
{{ filter_form.opening_date_range.label_tag }}
|
||||
@@ -155,16 +196,17 @@
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="height-section">
|
||||
@click="toggleSection('height-section')">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-ruler-vertical mr-2 text-gray-500"></i>
|
||||
Height & Safety
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||
:class="{ 'rotate-180': sections['height-section'] }"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="height-section" class="filter-content p-4 space-y-4">
|
||||
<div id="height-section" class="filter-content p-4 space-y-4" x-show="sections['height-section']" x-transition>
|
||||
<!-- Height Requirements -->
|
||||
<div>
|
||||
{{ filter_form.height_requirements.label_tag }}
|
||||
@@ -189,16 +231,17 @@
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="performance-section">
|
||||
@click="toggleSection('performance-section')">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-tachometer-alt mr-2 text-gray-500"></i>
|
||||
Performance
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||
:class="{ 'rotate-180': sections['performance-section'] }"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="performance-section" class="filter-content p-4 space-y-4">
|
||||
<div id="performance-section" class="filter-content p-4 space-y-4" x-show="sections['performance-section']" x-transition>
|
||||
<!-- Speed Range -->
|
||||
<div>
|
||||
{{ filter_form.speed_range.label_tag }}
|
||||
@@ -229,16 +272,17 @@
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="relationships-section">
|
||||
@click="toggleSection('relationships-section')">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-sitemap mr-2 text-gray-500"></i>
|
||||
Companies & Models
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||
:class="{ 'rotate-180': sections['relationships-section'] }"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="relationships-section" class="filter-content p-4 space-y-4">
|
||||
<div id="relationships-section" class="filter-content p-4 space-y-4" x-show="sections['relationships-section']" x-transition>
|
||||
<!-- Manufacturers -->
|
||||
<div>
|
||||
{{ filter_form.manufacturers.label_tag }}
|
||||
@@ -263,16 +307,17 @@
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="coaster-section">
|
||||
@click="toggleSection('coaster-section')">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-mountain mr-2 text-gray-500"></i>
|
||||
Roller Coaster Details
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||
:class="{ 'rotate-180': sections['coaster-section'] }"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="coaster-section" class="filter-content p-4 space-y-4">
|
||||
<div id="coaster-section" class="filter-content p-4 space-y-4" x-show="sections['coaster-section']" x-transition>
|
||||
<!-- Track Type -->
|
||||
<div>
|
||||
{{ filter_form.track_types.label_tag }}
|
||||
@@ -324,16 +369,17 @@
|
||||
<div class="filter-section">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="sorting-section">
|
||||
@click="toggleSection('sorting-section')">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-sort mr-2 text-gray-500"></i>
|
||||
Sorting
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||
:class="{ 'rotate-180': sections['sorting-section'] }"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="sorting-section" class="filter-content p-4 space-y-4">
|
||||
<div id="sorting-section" class="filter-content p-4 space-y-4" x-show="sections['sorting-section']" x-transition>
|
||||
<!-- Sort By -->
|
||||
<div>
|
||||
{{ filter_form.sort_by.label_tag }}
|
||||
@@ -350,116 +396,14 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Filter JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize collapsible sections
|
||||
initializeFilterSections();
|
||||
|
||||
// Initialize filter form handlers
|
||||
initializeFilterForm();
|
||||
});
|
||||
|
||||
function initializeFilterSections() {
|
||||
const toggles = document.querySelectorAll('.filter-toggle');
|
||||
|
||||
toggles.forEach(toggle => {
|
||||
toggle.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const content = document.getElementById(targetId);
|
||||
const chevron = this.querySelector('.fa-chevron-down');
|
||||
|
||||
if (content.style.display === 'none' || content.style.display === '') {
|
||||
content.style.display = 'block';
|
||||
chevron.style.transform = 'rotate(180deg)';
|
||||
localStorage.setItem(`filter-${targetId}`, 'open');
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
chevron.style.transform = 'rotate(0deg)';
|
||||
localStorage.setItem(`filter-${targetId}`, 'closed');
|
||||
}
|
||||
});
|
||||
|
||||
// Restore section state from localStorage
|
||||
const targetId = toggle.getAttribute('data-target');
|
||||
const content = document.getElementById(targetId);
|
||||
const chevron = toggle.querySelector('.fa-chevron-down');
|
||||
const state = localStorage.getItem(`filter-${targetId}`);
|
||||
|
||||
if (state === 'closed') {
|
||||
content.style.display = 'none';
|
||||
chevron.style.transform = 'rotate(0deg)';
|
||||
} else {
|
||||
content.style.display = 'block';
|
||||
chevron.style.transform = 'rotate(180deg)';
|
||||
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
|
||||
<div x-data="{
|
||||
init() {
|
||||
// Only essential HTMX error handling as shown in Context7 docs
|
||||
this.$el.addEventListener('htmx:responseError', (evt) => {
|
||||
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
|
||||
console.error('HTMX Error:', evt.detail.xhr.status);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initializeFilterForm() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
// Handle multi-select changes
|
||||
const selects = form.querySelectorAll('select[multiple]');
|
||||
selects.forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
// Trigger HTMX update
|
||||
htmx.trigger(form, 'change');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle range inputs
|
||||
const rangeInputs = form.querySelectorAll('input[type="range"], input[type="number"]');
|
||||
rangeInputs.forEach(input => {
|
||||
input.addEventListener('input', function() {
|
||||
// Debounced update
|
||||
clearTimeout(this.updateTimeout);
|
||||
this.updateTimeout = setTimeout(() => {
|
||||
htmx.trigger(form, 'input');
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeFilter(category, filterName) {
|
||||
const form = document.getElementById('filter-form');
|
||||
const input = form.querySelector(`[name*="${filterName}"]`);
|
||||
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = false;
|
||||
} else if (input.tagName === 'SELECT') {
|
||||
if (input.multiple) {
|
||||
Array.from(input.options).forEach(option => option.selected = false);
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// Trigger form update
|
||||
htmx.trigger(form, 'change');
|
||||
}
|
||||
}
|
||||
|
||||
// Update filter counts
|
||||
function updateFilterCounts() {
|
||||
const form = document.getElementById('filter-form');
|
||||
const formData = new FormData(form);
|
||||
let activeCount = 0;
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (value && value.trim() !== '') {
|
||||
activeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const badge = document.querySelector('.filter-count-badge');
|
||||
if (badge) {
|
||||
badge.textContent = activeCount;
|
||||
badge.style.display = activeCount > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
}"></div>
|
||||
|
||||
@@ -11,25 +11,27 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
selectManufacturer(id, name) {
|
||||
const manufacturerInput = document.getElementById('id_manufacturer');
|
||||
const manufacturerSearch = document.getElementById('id_manufacturer_search');
|
||||
const manufacturerResults = document.getElementById('manufacturer-search-results');
|
||||
// Use AlpineJS $el to scope queries within component
|
||||
const manufacturerInput = this.$el.querySelector('#id_manufacturer');
|
||||
const manufacturerSearch = this.$el.querySelector('#id_manufacturer_search');
|
||||
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
|
||||
|
||||
if (manufacturerInput) manufacturerInput.value = id;
|
||||
if (manufacturerSearch) manufacturerSearch.value = name;
|
||||
if (manufacturerResults) manufacturerResults.innerHTML = '';
|
||||
|
||||
// Update ride model search to include manufacturer
|
||||
const rideModelSearch = document.getElementById('id_ride_model_search');
|
||||
const rideModelSearch = this.$el.querySelector('#id_ride_model_search');
|
||||
if (rideModelSearch) {
|
||||
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
|
||||
}
|
||||
},
|
||||
|
||||
selectDesigner(id, name) {
|
||||
const designerInput = document.getElementById('id_designer');
|
||||
const designerSearch = document.getElementById('id_designer_search');
|
||||
const designerResults = document.getElementById('designer-search-results');
|
||||
// Use AlpineJS $el to scope queries within component
|
||||
const designerInput = this.$el.querySelector('#id_designer');
|
||||
const designerSearch = this.$el.querySelector('#id_designer_search');
|
||||
const designerResults = this.$el.querySelector('#designer-search-results');
|
||||
|
||||
if (designerInput) designerInput.value = id;
|
||||
if (designerSearch) designerSearch.value = name;
|
||||
@@ -37,9 +39,10 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
selectRideModel(id, name) {
|
||||
const rideModelInput = document.getElementById('id_ride_model');
|
||||
const rideModelSearch = document.getElementById('id_ride_model_search');
|
||||
const rideModelResults = document.getElementById('ride-model-search-results');
|
||||
// Use AlpineJS $el to scope queries within component
|
||||
const rideModelInput = this.$el.querySelector('#id_ride_model');
|
||||
const rideModelSearch = this.$el.querySelector('#id_ride_model_search');
|
||||
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
|
||||
|
||||
if (rideModelInput) rideModelInput.value = id;
|
||||
if (rideModelSearch) rideModelSearch.value = name;
|
||||
@@ -47,9 +50,10 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
clearAllSearchResults() {
|
||||
const manufacturerResults = document.getElementById('manufacturer-search-results');
|
||||
const designerResults = document.getElementById('designer-search-results');
|
||||
const rideModelResults = document.getElementById('ride-model-search-results');
|
||||
// Use AlpineJS $el to scope queries within component
|
||||
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
|
||||
const designerResults = this.$el.querySelector('#designer-search-results');
|
||||
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
|
||||
|
||||
if (manufacturerResults) manufacturerResults.innerHTML = '';
|
||||
if (designerResults) designerResults.innerHTML = '';
|
||||
@@ -57,17 +61,20 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
clearManufacturerResults() {
|
||||
const manufacturerResults = document.getElementById('manufacturer-search-results');
|
||||
// Use AlpineJS $el to scope queries within component
|
||||
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
|
||||
if (manufacturerResults) manufacturerResults.innerHTML = '';
|
||||
},
|
||||
|
||||
clearDesignerResults() {
|
||||
const designerResults = document.getElementById('designer-search-results');
|
||||
// Use AlpineJS $el to scope queries within component
|
||||
const designerResults = this.$el.querySelector('#designer-search-results');
|
||||
if (designerResults) designerResults.innerHTML = '';
|
||||
},
|
||||
|
||||
clearRideModelResults() {
|
||||
const rideModelResults = document.getElementById('ride-model-search-results');
|
||||
// Use AlpineJS $el to scope queries within component
|
||||
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
|
||||
if (rideModelResults) rideModelResults.innerHTML = '';
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -1,365 +1,122 @@
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('rideSearch', () => ({
|
||||
init() {
|
||||
// Initialize from URL params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.searchQuery = urlParams.get('search') || '';
|
||||
|
||||
// Bind to form reset
|
||||
document.querySelector('form').addEventListener('reset', () => {
|
||||
this.searchQuery = '';
|
||||
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
|
||||
<div x-data="{
|
||||
searchQuery: new URLSearchParams(window.location.search).get('search') || '',
|
||||
showSuggestions: false,
|
||||
selectedIndex: -1,
|
||||
|
||||
init() {
|
||||
// Watch for URL changes
|
||||
this.$watch('searchQuery', value => {
|
||||
if (value.length >= 2) {
|
||||
this.showSuggestions = true;
|
||||
} else {
|
||||
this.showSuggestions = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle clicks outside to close suggestions
|
||||
this.$el.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) {
|
||||
this.showSuggestions = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleInput() {
|
||||
// HTMX will handle the actual search request
|
||||
if (this.searchQuery.length >= 2) {
|
||||
this.showSuggestions = true;
|
||||
} else {
|
||||
this.showSuggestions = false;
|
||||
}
|
||||
},
|
||||
|
||||
selectSuggestion(text) {
|
||||
this.searchQuery = text;
|
||||
this.showSuggestions = false;
|
||||
// Update the search input
|
||||
this.$refs.searchInput.value = text;
|
||||
// Trigger form change for HTMX
|
||||
this.$refs.searchForm.dispatchEvent(new Event('change'));
|
||||
},
|
||||
|
||||
handleKeydown(e) {
|
||||
const suggestions = this.$el.querySelectorAll('#search-suggestions button');
|
||||
if (!suggestions.length) return;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (this.selectedIndex < suggestions.length - 1) {
|
||||
this.selectedIndex++;
|
||||
suggestions[this.selectedIndex].focus();
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (this.selectedIndex > 0) {
|
||||
this.selectedIndex--;
|
||||
suggestions[this.selectedIndex].focus();
|
||||
} else {
|
||||
this.$refs.searchInput.focus();
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
this.showSuggestions = false;
|
||||
this.selectedIndex = -1;
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
// Handle clicks outside suggestions
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) {
|
||||
this.showSuggestions = false;
|
||||
this.$refs.searchInput.blur();
|
||||
break;
|
||||
case 'Enter':
|
||||
if (e.target.tagName === 'BUTTON') {
|
||||
e.preventDefault();
|
||||
this.selectSuggestion(e.target.dataset.text);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle HTMX errors
|
||||
document.body.addEventListener('htmx:error', (evt) => {
|
||||
console.error('HTMX Error:', evt.detail.error);
|
||||
this.showError('An error occurred while searching. Please try again.');
|
||||
});
|
||||
|
||||
// Store bound handlers for cleanup
|
||||
this.boundHandlers = new Map();
|
||||
|
||||
// Create handler functions
|
||||
const popstateHandler = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.searchQuery = urlParams.get('search') || '';
|
||||
this.syncFormWithUrl();
|
||||
};
|
||||
this.boundHandlers.set('popstate', popstateHandler);
|
||||
|
||||
const errorHandler = (evt) => {
|
||||
console.error('HTMX Error:', evt.detail.error);
|
||||
this.showError('An error occurred while searching. Please try again.');
|
||||
};
|
||||
this.boundHandlers.set('htmx:error', errorHandler);
|
||||
|
||||
// Bind event listeners
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
document.body.addEventListener('htmx:error', errorHandler);
|
||||
|
||||
// Restore filters from localStorage if no URL params exist
|
||||
const savedFilters = localStorage.getItem('rideFilters');
|
||||
|
||||
// Set up destruction handler
|
||||
this.$cleanup = this.performCleanup.bind(this);
|
||||
if (savedFilters) {
|
||||
const filters = JSON.parse(savedFilters);
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
const input = document.querySelector(`[name="${key}"]`);
|
||||
if (input) input.value = value;
|
||||
});
|
||||
// Trigger search with restored filters
|
||||
document.querySelector('form').dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
// Set up filter persistence
|
||||
document.querySelector('form').addEventListener('change', (e) => {
|
||||
this.saveFilters();
|
||||
});
|
||||
},
|
||||
|
||||
showSuggestions: false,
|
||||
loading: false,
|
||||
searchQuery: '',
|
||||
suggestionTimeout: null,
|
||||
|
||||
// Save current filters to localStorage
|
||||
saveFilters() {
|
||||
const form = document.querySelector('form');
|
||||
const formData = new FormData(form);
|
||||
const filters = {};
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (value) filters[key] = value;
|
||||
}
|
||||
localStorage.setItem('rideFilters', JSON.stringify(filters));
|
||||
},
|
||||
|
||||
// Clear all filters
|
||||
clearFilters() {
|
||||
document.querySelectorAll('form select, form input').forEach(el => {
|
||||
el.value = '';
|
||||
});
|
||||
localStorage.removeItem('rideFilters');
|
||||
document.querySelector('form').dispatchEvent(new Event('change'));
|
||||
},
|
||||
|
||||
// Get search suggestions with request tracking
|
||||
lastRequestId: 0,
|
||||
currentRequest: null,
|
||||
|
||||
getSearchSuggestions() {
|
||||
if (this.searchQuery.length < 2) {
|
||||
break;
|
||||
case 'Tab':
|
||||
this.showSuggestions = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending request
|
||||
if (this.currentRequest) {
|
||||
this.currentRequest.abort();
|
||||
}
|
||||
|
||||
const requestId = ++this.lastRequestId;
|
||||
const controller = new AbortController();
|
||||
this.currentRequest = controller;
|
||||
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||
|
||||
this.fetchSuggestions(controller, requestId, () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (this.currentRequest === controller) {
|
||||
this.currentRequest = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
fetchSuggestions(controller, requestId) {
|
||||
const parkSlug = document.querySelector('input[name="park_slug"]')?.value;
|
||||
const queryParams = {
|
||||
q: this.searchQuery
|
||||
};
|
||||
if (parkSlug) {
|
||||
queryParams.park_slug = parkSlug;
|
||||
}
|
||||
|
||||
// Create temporary form for HTMX request
|
||||
const tempForm = document.createElement('form');
|
||||
tempForm.setAttribute('hx-get', '/rides/search-suggestions/');
|
||||
tempForm.setAttribute('hx-vals', JSON.stringify(queryParams));
|
||||
tempForm.setAttribute('hx-trigger', 'submit');
|
||||
tempForm.setAttribute('hx-swap', 'none');
|
||||
|
||||
// Add request ID header simulation
|
||||
tempForm.setAttribute('hx-headers', JSON.stringify({
|
||||
'X-Request-ID': requestId.toString()
|
||||
}));
|
||||
|
||||
// Handle abort signal
|
||||
if (controller.signal.aborted) {
|
||||
this.handleSuggestionError(new Error('AbortError'), requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
if (document.body.contains(tempForm)) {
|
||||
document.body.removeChild(tempForm);
|
||||
}
|
||||
this.handleSuggestionError(new Error('AbortError'), requestId);
|
||||
};
|
||||
controller.signal.addEventListener('abort', abortHandler);
|
||||
|
||||
tempForm.addEventListener('htmx:afterRequest', (event) => {
|
||||
controller.signal.removeEventListener('abort', abortHandler);
|
||||
|
||||
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
|
||||
this.handleSuggestionResponse(event.detail.xhr, requestId);
|
||||
} else {
|
||||
this.handleSuggestionError(new Error(`HTTP error! status: ${event.detail.xhr.status}`), requestId);
|
||||
}
|
||||
|
||||
if (document.body.contains(tempForm)) {
|
||||
document.body.removeChild(tempForm);
|
||||
}
|
||||
});
|
||||
|
||||
tempForm.addEventListener('htmx:error', (event) => {
|
||||
controller.signal.removeEventListener('abort', abortHandler);
|
||||
this.handleSuggestionError(new Error(`HTTP error! status: ${event.detail.xhr.status || 'unknown'}`), requestId);
|
||||
|
||||
if (document.body.contains(tempForm)) {
|
||||
document.body.removeChild(tempForm);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(tempForm);
|
||||
htmx.trigger(tempForm, 'submit');
|
||||
},
|
||||
|
||||
handleSuggestionResponse(xhr, requestId) {
|
||||
if (requestId === this.lastRequestId && this.searchQuery === document.getElementById('search').value) {
|
||||
const html = xhr.responseText || '';
|
||||
const suggestionsEl = document.getElementById('search-suggestions');
|
||||
suggestionsEl.innerHTML = html;
|
||||
this.showSuggestions = Boolean(html.trim());
|
||||
|
||||
this.updateAriaAttributes(suggestionsEl);
|
||||
}
|
||||
},
|
||||
|
||||
updateAriaAttributes(suggestionsEl) {
|
||||
const searchInput = document.getElementById('search');
|
||||
searchInput.setAttribute('aria-expanded', this.showSuggestions.toString());
|
||||
searchInput.setAttribute('aria-controls', 'search-suggestions');
|
||||
if (this.showSuggestions) {
|
||||
suggestionsEl.setAttribute('role', 'listbox');
|
||||
suggestionsEl.querySelectorAll('button').forEach(btn => {
|
||||
btn.setAttribute('role', 'option');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleSuggestionError(error, requestId) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.warn('Search suggestion request timed out or cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Error fetching suggestions:', error);
|
||||
if (requestId === this.lastRequestId) {
|
||||
const suggestionsEl = document.getElementById('search-suggestions');
|
||||
suggestionsEl.innerHTML = `
|
||||
<div class="p-2 text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
Failed to load suggestions. Please try again.
|
||||
</div>`;
|
||||
this.showSuggestions = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Handle input changes with debounce
|
||||
handleInput() {
|
||||
clearTimeout(this.suggestionTimeout);
|
||||
this.suggestionTimeout = setTimeout(() => {
|
||||
this.getSearchSuggestions();
|
||||
}, 200);
|
||||
},
|
||||
|
||||
// Handle suggestion selection
|
||||
// Sync form with URL parameters
|
||||
syncFormWithUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const form = document.querySelector('form');
|
||||
|
||||
// Clear existing values
|
||||
form.querySelectorAll('input, select').forEach(el => {
|
||||
if (el.type !== 'hidden') el.value = '';
|
||||
});
|
||||
|
||||
// Set values from URL
|
||||
urlParams.forEach((value, key) => {
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
if (input) input.value = value;
|
||||
});
|
||||
|
||||
// Trigger form update
|
||||
form.dispatchEvent(new Event('change'));
|
||||
},
|
||||
|
||||
// Cleanup resources
|
||||
cleanup() {
|
||||
clearTimeout(this.suggestionTimeout);
|
||||
this.showSuggestions = false;
|
||||
localStorage.removeItem('rideFilters');
|
||||
},
|
||||
|
||||
selectSuggestion(text) {
|
||||
this.searchQuery = text;
|
||||
this.showSuggestions = false;
|
||||
document.getElementById('search').value = text;
|
||||
|
||||
// Update URL with search parameter
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('search', text);
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
document.querySelector('form').dispatchEvent(new Event('change'));
|
||||
},
|
||||
|
||||
// Handle keyboard navigation
|
||||
// Show error message
|
||||
showError(message) {
|
||||
const searchInput = document.getElementById('search');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'text-red-600 text-sm mt-1';
|
||||
errorDiv.textContent = message;
|
||||
searchInput.parentNode.appendChild(errorDiv);
|
||||
setTimeout(() => errorDiv.remove(), 3000);
|
||||
},
|
||||
|
||||
// Handle keyboard navigation
|
||||
handleKeydown(e) {
|
||||
const suggestions = document.querySelectorAll('#search-suggestions button');
|
||||
if (!suggestions.length) return;
|
||||
|
||||
const currentIndex = Array.from(suggestions).findIndex(el => el === document.activeElement);
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (currentIndex < 0) {
|
||||
suggestions[0].focus();
|
||||
this.selectedIndex = 0;
|
||||
} else if (currentIndex < suggestions.length - 1) {
|
||||
suggestions[currentIndex + 1].focus();
|
||||
this.selectedIndex = currentIndex + 1;
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (currentIndex > 0) {
|
||||
suggestions[currentIndex - 1].focus();
|
||||
this.selectedIndex = currentIndex - 1;
|
||||
} else {
|
||||
document.getElementById('search').focus();
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
this.showSuggestions = false;
|
||||
this.selectedIndex = -1;
|
||||
document.getElementById('search').blur();
|
||||
break;
|
||||
case 'Enter':
|
||||
if (document.activeElement.tagName === 'BUTTON') {
|
||||
e.preventDefault();
|
||||
this.selectSuggestion(document.activeElement.dataset.text);
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
this.showSuggestions = false;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}));
|
||||
});
|
||||
},
|
||||
}
|
||||
}"
|
||||
@click.outside="showSuggestions = false">
|
||||
|
||||
performCleanup() {
|
||||
// Remove all bound event listeners
|
||||
this.boundHandlers.forEach(this.removeEventHandler.bind(this));
|
||||
this.boundHandlers.clear();
|
||||
|
||||
// Cancel any pending requests
|
||||
if (this.currentRequest) {
|
||||
this.currentRequest.abort();
|
||||
this.currentRequest = null;
|
||||
}
|
||||
|
||||
// Clear any pending timeouts
|
||||
if (this.suggestionTimeout) {
|
||||
clearTimeout(this.suggestionTimeout);
|
||||
}
|
||||
},
|
||||
|
||||
removeEventHandler(handler, event) {
|
||||
if (event === 'popstate') {
|
||||
window.removeEventListener(event, handler);
|
||||
} else {
|
||||
document.body.removeEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
<!-- Search Input with HTMX -->
|
||||
<input
|
||||
x-ref="searchInput"
|
||||
x-model="searchQuery"
|
||||
@input="handleInput()"
|
||||
@keydown="handleKeydown($event)"
|
||||
hx-get="/rides/search-suggestions/"
|
||||
hx-trigger="input changed delay:200ms"
|
||||
hx-target="#search-suggestions"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='park_slug']"
|
||||
:aria-expanded="showSuggestions"
|
||||
aria-controls="search-suggestions"
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
placeholder="Search rides..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
|
||||
<!-- Suggestions Container -->
|
||||
<div
|
||||
x-show="showSuggestions"
|
||||
x-transition
|
||||
id="search-suggestions"
|
||||
role="listbox"
|
||||
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||
>
|
||||
<!-- HTMX will populate this -->
|
||||
</div>
|
||||
|
||||
<!-- Form Reference for HTMX -->
|
||||
<form x-ref="searchForm" style="display: none;">
|
||||
<!-- Hidden form for HTMX reference -->
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- HTMX Loading Indicator Styles -->
|
||||
<style>
|
||||
@@ -368,10 +125,9 @@ document.addEventListener('alpine:init', () => {
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Enhanced Loading Indicator */
|
||||
.loading-indicator {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
@@ -396,60 +152,14 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Initialize request timeout management
|
||||
const timeouts = new Map();
|
||||
|
||||
// Handle request start
|
||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
const timestamp = document.querySelector('.loading-timestamp');
|
||||
if (timestamp) {
|
||||
timestamp.textContent = new Date().toLocaleTimeString();
|
||||
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
|
||||
<div x-data="{
|
||||
init() {
|
||||
// Only essential HTMX error handling as shown in Context7 docs
|
||||
this.$el.addEventListener('htmx:responseError', (evt) => {
|
||||
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
|
||||
console.error('HTMX Error:', evt.detail.xhr.status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set timeout for request
|
||||
const timeoutId = setTimeout(() => {
|
||||
evt.detail.xhr.abort();
|
||||
showError('Request timed out. Please try again.');
|
||||
}, 10000); // 10s timeout
|
||||
|
||||
timeouts.set(evt.detail.xhr, timeoutId);
|
||||
});
|
||||
|
||||
// Handle request completion
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const timeoutId = timeouts.get(evt.detail.xhr);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeouts.delete(evt.detail.xhr);
|
||||
}
|
||||
|
||||
if (!evt.detail.successful) {
|
||||
showError('Failed to update results. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
function showError(message) {
|
||||
const indicator = document.querySelector('.loading-indicator');
|
||||
if (indicator) {
|
||||
indicator.innerHTML = `
|
||||
<div class="flex items-center text-red-100">
|
||||
<i class="mr-2 fas fa-exclamation-circle"></i>
|
||||
<span>${message}</span>
|
||||
</div>`;
|
||||
setTimeout(() => {
|
||||
indicator.innerHTML = originalIndicatorContent;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Store original indicator content
|
||||
const originalIndicatorContent = document.querySelector('.loading-indicator')?.innerHTML;
|
||||
|
||||
// Reset loading state when navigating away
|
||||
window.addEventListener('beforeunload', () => {
|
||||
timeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
timeouts.clear();
|
||||
});
|
||||
</script>
|
||||
}"></div>
|
||||
|
||||
@@ -15,26 +15,7 @@
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="{
|
||||
status: '{{ form.instance.status|default:'OPERATING' }}',
|
||||
clearResults(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (container && !container.contains(event.target)) {
|
||||
container.querySelector('[id$=search-results]').innerHTML = '';
|
||||
}
|
||||
},
|
||||
handleStatusChange(event) {
|
||||
this.status = event.target.value;
|
||||
if (this.status === 'CLOSING') {
|
||||
document.getElementById('id_closing_date').required = true;
|
||||
} else {
|
||||
document.getElementById('id_closing_date').required = false;
|
||||
}
|
||||
},
|
||||
showClosingDate() {
|
||||
return ['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(this.status);
|
||||
}
|
||||
}">
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="rideFormData()">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if not park %}
|
||||
@@ -242,4 +223,41 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('rideFormData', () => ({
|
||||
status: '{{ form.instance.status|default:"OPERATING" }}',
|
||||
|
||||
init() {
|
||||
// Watch for status changes on the status select element
|
||||
this.$watch('status', (value) => {
|
||||
const closingDateField = this.$el.querySelector('#id_closing_date');
|
||||
if (closingDateField) {
|
||||
closingDateField.required = value === 'CLOSING';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearResults(containerId) {
|
||||
// Use AlpineJS $el to find container within component scope
|
||||
const container = this.$el.querySelector(`#${containerId}`);
|
||||
if (container) {
|
||||
const resultsDiv = container.querySelector('[id$="search-results"]');
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
handleStatusChange(event) {
|
||||
this.status = event.target.value;
|
||||
},
|
||||
|
||||
showClosingDate() {
|
||||
return ['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(this.status);
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user