mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 06:31:09 -05:00
402 lines
14 KiB
HTML
402 lines
14 KiB
HTML
<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 = '';
|
|
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;
|
|
}
|
|
});
|
|
|
|
// 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,
|
|
|
|
async getSearchSuggestions() {
|
|
if (this.searchQuery.length < 2) {
|
|
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
|
|
|
|
try {
|
|
const parkSlug = document.querySelector('input[name="park_slug"]')?.value;
|
|
const url = `/rides/search-suggestions/?q=${encodeURIComponent(this.searchQuery)}${parkSlug ? '&park_slug=' + parkSlug : ''}`;
|
|
|
|
const response = await fetch(url, {
|
|
signal: controller.signal,
|
|
headers: {
|
|
'X-Request-ID': requestId.toString()
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const html = await response.text();
|
|
|
|
// Only update if this is still the most recent request
|
|
if (requestId === this.lastRequestId && this.searchQuery === document.getElementById('search').value) {
|
|
const suggestionsEl = document.getElementById('search-suggestions');
|
|
suggestionsEl.innerHTML = html;
|
|
this.showSuggestions = html.trim() ? true : false;
|
|
|
|
// Set proper ARIA attributes
|
|
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');
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
console.warn('Search suggestion request timed out or cancelled');
|
|
} else {
|
|
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;
|
|
}
|
|
}
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
if (this.currentRequest === controller) {
|
|
this.currentRequest = null;
|
|
}
|
|
}
|
|
},
|
|
|
|
// Handle input changes with debounce
|
|
async 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;
|
|
}
|
|
}
|
|
}));
|
|
});
|
|
</script>
|
|
|
|
<!-- HTMX Loading Indicator Styles -->
|
|
<style>
|
|
.htmx-indicator {
|
|
opacity: 0;
|
|
transition: opacity 200ms ease-in;
|
|
}
|
|
.htmx-request .htmx-indicator {
|
|
opacity: 1
|
|
}
|
|
|
|
/* Enhanced Loading Indicator */
|
|
.loading-indicator {
|
|
position: fixed;
|
|
bottom: 1rem;
|
|
right: 1rem;
|
|
z-index: 50;
|
|
},
|
|
|
|
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>
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: white;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.loading-indicator svg {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</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();
|
|
}
|
|
|
|
// 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> |