Files
thrillwiki_django_no_react/templates/rides/partials/search_script.html
pacnpal 12deafaa09 Refactor photo management and upload functionality to use HTMX for asynchronous requests
- Updated photo upload handling in `photo_manager.html` and `photo_upload.html` to utilize HTMX for file uploads, improving user experience and reducing reliance on Promises.
- Refactored caption update and primary photo toggle methods to leverage HTMX for state updates without full page reloads.
- Enhanced error handling and success notifications using HTMX events.
- Replaced fetch API calls with HTMX forms in various templates, including `homepage.html`, `park_form.html`, and `roadtrip_planner.html`, to streamline AJAX interactions.
- Improved search suggestion functionality in `search_script.html` by implementing HTMX for fetching suggestions, enhancing performance and user experience.
- Updated designer, manufacturer, and ride model forms to handle responses with HTMX, ensuring better integration and user feedback.
2025-09-26 10:18:56 -04:00

456 lines
16 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,
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
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;
}
}
}));
});
},
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>
<!-- 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;
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>