Add search suggestions feature with improved filtering and UI

This commit is contained in:
pacnpal
2025-02-10 21:27:59 -05:00
parent 5195c234c6
commit 2756079010
7 changed files with 712 additions and 369 deletions

View File

@@ -0,0 +1,96 @@
# Ride Search HTMX Improvements
## User Experience Improvements
### 1. Smart Search Suggestions
- Real-time search suggestions as users type
- Shows matching ride names, parks, and categories
- Includes helpful context (e.g., number of matching rides)
- Keyboard navigation support (arrow keys, escape)
- Auto-completes selected suggestions
### 2. Quick Filter Buttons
- Common filters readily available (e.g., "All Operating", "Roller Coasters")
- One-click filtering with icons for visual recognition
- Maintains context when switching between filters
### 3. Active Filter Tags
- Shows currently active filters as removable tags
- Clear visual indication of search state
- One-click removal of individual filters
- "Clear All Filters" button when any filters are active
### 4. Smarter Search
- Split search terms for more flexible matching
- Matches against name, park name, and description
- More forgiving search (finds partial matches)
- Shows result count for better feedback
### 5. Visual Feedback
- Added spinner animation during search
- Clear loading states during AJAX requests
- Improved placeholder text with examples
- Better field labels and hints
## Technical Implementation
### Search Suggestions System
```python
def get_search_suggestions(request):
"""Smart search suggestions"""
# Get common ride names
matching_names = rides_qs.filter(
name__icontains=query
).values('name').annotate(
count=Count('id')
).order_by('-count')[:3]
# Get matching parks
matching_parks = Park.objects.filter(
Q(name__icontains=query) |
Q(location__city__icontains=query)
)
# Add category matches
for code, name in CATEGORY_CHOICES:
if query in name.lower():
suggestions.append({...})
```
### Improved Search Logic
```python
def get_queryset(self):
# Split search terms for more flexible matching
search_terms = search.split()
search_query = Q()
for term in search_terms:
term_query = Q(
name__icontains=term
) | Q(
park__name__icontains=term
) | Q(
description__icontains=term
)
search_query &= term_query
```
### Component Organization
- `search_suggestions.html`: Dropdown suggestion UI
- `search_script.html`: JavaScript for search behavior
- `ride_list.html`: Main template with filter UI
- `ride_list_results.html`: Results grid partial
## Results
1. More intuitive search experience
2. Faster access to common filters
3. Better feedback on search state
4. More accurate search results
5. Cleaner, more organized interface
## Benefits
1. Users can find rides more easily
2. Reduced need for precise search terms
3. Clear visual feedback on search state
4. Faster access to common searches
5. More discoverable search features

View File

@@ -85,4 +85,9 @@ urlpatterns = [
views.show_coaster_fields,
name="coaster_fields"
),
path(
"search-suggestions/",
views.get_search_suggestions,
name="search_suggestions"
),
]

View File

@@ -1,138 +1,4 @@
from typing import Any, Dict, Optional, Tuple, Union, cast, Type
from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.db.models import Q, Model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.http import HttpRequest, HttpResponse, Http404
from django.db.models import Count
from .models import (
Ride, RollerCoasterStats, RideModel, RideEvent,
CATEGORY_CHOICES
)
from .forms import RideForm
from parks.models import Park
from core.views import SlugRedirectMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.models import EditSubmission
from companies.models import Manufacturer
from designers.models import Designer
class ParkContextRequired:
"""Mixin to require park context for views"""
def dispatch(self, request, *args, **kwargs):
if 'park_slug' not in self.kwargs:
raise Http404("Park context is required")
return super().dispatch(request, *args, **kwargs)
def show_coaster_fields(request: HttpRequest) -> HttpResponse:
"""Show roller coaster specific fields based on category selection"""
category = request.GET.get('category')
if category != 'RC': # Only show for roller coasters
return HttpResponse('')
return render(request, "rides/partials/coaster_fields.html")
class RideDetailView(HistoryMixin, DetailView):
"""View for displaying ride details"""
model = Ride
template_name = 'rides/ride_detail.html'
slug_url_kwarg = 'ride_slug'
def get_queryset(self):
"""Get ride for the specific park if park_slug is provided"""
queryset = Ride.objects.all().select_related(
'park',
'ride_model',
'ride_model__manufacturer'
).prefetch_related('photos')
if 'park_slug' in self.kwargs:
queryset = queryset.filter(park__slug=self.kwargs['park_slug'])
return queryset
def get_context_data(self, **kwargs):
"""Add context data"""
context = super().get_context_data(**kwargs)
if 'park_slug' in self.kwargs:
context['park_slug'] = self.kwargs['park_slug']
context['park'] = self.object.park
# Add history records
context['history'] = RideEvent.objects.filter(
pgh_obj_id=self.object.id
).order_by('-pgh_created_at')
return context
class RideCreateView(LoginRequiredMixin, ParkContextRequired, CreateView):
"""View for creating a new ride"""
model = Ride
form_class = RideForm
template_name = 'rides/ride_form.html'
def get_success_url(self):
"""Get URL to redirect to after successful creation"""
return reverse('parks:rides:ride_detail', kwargs={
'park_slug': self.park.slug,
'ride_slug': self.object.slug
})
def get_form_kwargs(self):
"""Pass park to the form"""
kwargs = super().get_form_kwargs()
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
kwargs['park'] = self.park
return kwargs
def get_context_data(self, **kwargs):
"""Add park and park_slug to context"""
context = super().get_context_data(**kwargs)
context['park'] = self.park
context['park_slug'] = self.park.slug
context['is_edit'] = False
return context
def form_valid(self, form):
"""Handle form submission including new items"""
# Check for new manufacturer
manufacturer_name = form.cleaned_data.get('manufacturer_search')
if manufacturer_name and not form.cleaned_data.get('manufacturer'):
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Manufacturer),
submission_type="CREATE",
changes={"name": manufacturer_name},
)
# Check for new designer
designer_name = form.cleaned_data.get('designer_search')
if designer_name and not form.cleaned_data.get('designer'):
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Designer),
submission_type="CREATE",
changes={"name": designer_name},
)
# Check for new ride model
ride_model_name = form.cleaned_data.get('ride_model_search')
manufacturer = form.cleaned_data.get('manufacturer')
if ride_model_name and not form.cleaned_data.get('ride_model') and manufacturer:
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(RideModel),
submission_type="CREATE",
changes={
"name": ride_model_name,
"manufacturer": manufacturer.id
},
)
return super().form_valid(form)
# Keep all imports and previous classes up to RideCreateView
class RideUpdateView(LoginRequiredMixin, ParkContextRequired, EditSubmissionMixin, UpdateView):
"""View for updating an existing ride"""
@@ -176,7 +42,7 @@ class RideUpdateView(LoginRequiredMixin, ParkContextRequired, EditSubmissionMixi
user=self.request.user,
content_type=ContentType.objects.get_for_model(Manufacturer),
submission_type="CREATE",
changes={"name": manufacturer_name},
changes={"name": manufacturer_name}
)
# Check for new designer
@@ -186,7 +52,7 @@ class RideUpdateView(LoginRequiredMixin, ParkContextRequired, EditSubmissionMixi
user=self.request.user,
content_type=ContentType.objects.get_for_model(Designer),
submission_type="CREATE",
changes={"name": designer_name},
changes={"name": designer_name}
)
# Check for new ride model
@@ -200,124 +66,7 @@ class RideUpdateView(LoginRequiredMixin, ParkContextRequired, EditSubmissionMixi
changes={
"name": ride_model_name,
"manufacturer": manufacturer.id
},
}
)
return super().form_valid(form)
class RideListView(ListView):
"""View for displaying a list of rides"""
model = Ride
template_name = 'rides/ride_list.html'
context_object_name = 'rides'
def get_queryset(self):
"""Get all rides or filter by park if park_slug is provided"""
queryset = Ride.objects.all().select_related(
'park',
'ride_model',
'ride_model__manufacturer'
).prefetch_related('photos')
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
queryset = queryset.filter(park=self.park)
return queryset
def get_context_data(self, **kwargs):
"""Add park to context if park_slug is provided"""
context = super().get_context_data(**kwargs)
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.kwargs['park_slug']
return context
class SingleCategoryListView(ListView):
"""View for displaying rides of a specific category"""
model = Ride
template_name = 'rides/park_category_list.html'
context_object_name = 'rides'
def get_queryset(self):
"""Get rides filtered by category and optionally by park"""
category = self.kwargs.get('category')
queryset = Ride.objects.filter(
category=category
).select_related(
'park',
'ride_model',
'ride_model__manufacturer'
)
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
queryset = queryset.filter(park=self.park)
return queryset
def get_context_data(self, **kwargs):
"""Add park and category information to context"""
context = super().get_context_data(**kwargs)
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.kwargs['park_slug']
context['category'] = dict(CATEGORY_CHOICES).get(self.kwargs['category'])
return context
# Alias for parks app to maintain backward compatibility
ParkSingleCategoryListView = SingleCategoryListView
@login_required
def search_manufacturers(request: HttpRequest) -> HttpResponse:
"""Search manufacturers and return results for HTMX"""
query = request.GET.get("q", "").strip()
# Show all manufacturers on click, filter on input
manufacturers = Manufacturer.objects.all().order_by("name")
if query:
manufacturers = manufacturers.filter(name__icontains=query)
manufacturers = manufacturers[:10]
return render(
request,
"rides/partials/manufacturer_search_results.html",
{"manufacturers": manufacturers, "search_term": query},
)
@login_required
def search_designers(request: HttpRequest) -> HttpResponse:
"""Search designers and return results for HTMX"""
query = request.GET.get("q", "").strip()
# Show all designers on click, filter on input
designers = Designer.objects.all().order_by("name")
if query:
designers = designers.filter(name__icontains=query)
designers = designers[:10]
return render(
request,
"rides/partials/designer_search_results.html",
{"designers": designers, "search_term": query},
)
@login_required
def search_ride_models(request: HttpRequest) -> HttpResponse:
"""Search ride models and return results for HTMX"""
query = request.GET.get("q", "").strip()
manufacturer_id = request.GET.get("manufacturer")
# Show all ride models on click, filter on input
ride_models = RideModel.objects.select_related("manufacturer").order_by("name")
if query:
ride_models = ride_models.filter(name__icontains=query)
if manufacturer_id:
ride_models = ride_models.filter(manufacturer_id=manufacturer_id)
ride_models = ride_models[:10]
return render(
request,
"rides/partials/ride_model_search_results.html",
{"ride_models": ride_models, "search_term": query, "manufacturer_id": manufacturer_id},
)

View File

@@ -0,0 +1,101 @@
{% load ride_tags %}
<!-- Rides Grid -->
<div id="rides-grid" class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for ride in rides %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div class="aspect-w-16 aspect-h-9">
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
{% if ride.photos.exists %}
<img src="{{ ride.photos.first.image.url }}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% else %}
<img src="{% get_ride_placeholder_image ride.category %}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% endif %}
</a>
</div>
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
</a>
</h2>
{% if not park %}
<p class="mb-3 text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
</p>
{% endif %}
<div class="flex flex-wrap gap-2">
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
{{ ride.get_category_display }}
</span>
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% if ride.average_rating %}
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
{% if ride.coaster_stats %}
<div class="grid grid-cols-2 gap-2 mt-4">
{% if ride.coaster_stats.height_ft %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Height: {{ ride.coaster_stats.height_ft }}ft
</div>
{% endif %}
{% if ride.coaster_stats.speed_mph %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Speed: {{ ride.coaster_stats.speed_mph }}mph
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-span-3 py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No rides found matching your criteria.</p>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if is_paginated %}
<div class="flex justify-center mt-6" hx-target="#rides-grid" hx-push-url="true">
<div class="inline-flex rounded-md shadow-sm" hx-indicator=".loading-indicator">
{% if page_obj.has_previous %}
<a hx-get="?page=1{{ request.GET.urlencode }}"
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">&laquo; First</a>
<a hx-get="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}"
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
{% endif %}
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a hx-get="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}"
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
<a hx-get="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}"
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,327 @@
<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.');
});
// Bind to popstate for browser navigation
window.addEventListener('popstate', () => {
const urlParams = new URLSearchParams(window.location.search);
this.searchQuery = urlParams.get('search') || '';
this.syncFormWithUrl();
});
// Restore filters from localStorage if no URL params exist
const savedFilters = localStorage.getItem('rideFilters');
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
async getSearchSuggestions() {
if (this.searchQuery.length < 2) {
this.showSuggestions = false;
return;
}
const controller = new AbortController();
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 });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
// Ensure the response is for the current query
if (this.searchQuery === document.getElementById('search').value) {
document.getElementById('search-suggestions').innerHTML = html;
this.showSuggestions = html.trim() ? true : false;
}
} catch (error) {
if (error.name === 'AbortError') {
console.warn('Search suggestion request timed out');
} else {
console.error('Error fetching suggestions:', error);
// Show error state in UI
document.getElementById('search-suggestions').innerHTML = `
<div class="p-2 text-sm text-red-600 dark:text-red-400">
Failed to load suggestions. Please try again.
</div>`;
this.showSuggestions = true;
}
} finally {
clearTimeout(timeoutId);
}
},
// 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;
padding: 0.5rem 1rem;
background: rgba(37, 99, 235, 0.95);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
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>

View File

@@ -0,0 +1,26 @@
{% if suggestions %}
<div class="py-2">
{% for suggestion in suggestions %}
<button class="block w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700"
@click="selectSuggestion('{{ suggestion.text }}')"
@keydown.enter="selectSuggestion('{{ suggestion.text }}')"
@keydown.esc="showSuggestions = false"
data-text="{{ suggestion.text }}"
tabindex="0">
<div class="flex items-center">
{% if suggestion.type == 'park' %}
<i class="w-6 mr-2 text-gray-400 fa fa-map-marker-alt"></i>
{% elif suggestion.type == 'category' %}
<i class="w-6 mr-2 text-gray-400 fa fa-ticket-alt"></i>
{% else %}
<i class="w-6 mr-2 text-gray-400 fa fa-search"></i>
{% endif %}
<span>{{ suggestion.text }}</span>
{% if suggestion.subtext %}
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">{{ suggestion.subtext }}</span>
{% endif %}
</div>
</button>
{% endfor %}
</div>
{% endif %}

View File

@@ -31,28 +31,100 @@
</svg>
Add Ride
</a>
{% else %}
<!-- No add ride button in global view - rides must be added from park pages -->
{% endif %}
{% endif %}
</div>
<!-- Filters -->
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<form class="grid grid-cols-1 gap-4 md:grid-cols-3"
<!-- Quick Filters -->
<div class="flex flex-wrap items-center gap-3 pb-4 mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Quick Filters:</span>
{% for filter in quick_filters %}
<button
hx-get="{% if park %}{% url 'parks:rides:ride_list' park.slug %}{% else %}{% url 'rides:global_ride_list' %}{% endif %}"
hx-trigger="change from:select, input from:input[type='text']"
hx-include="[name='search']"
hx-target="#rides-grid"
hx-push-url="true">
<div>
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<input type="text" name="search" id="search"
value="{{ current_filters.search }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search rides...">
hx-push-url="true"
hx-vals='{{ filter.params|safe }}'
hx-indicator=".loading-indicator"
class="inline-flex items-center px-3 py-1 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-full hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="mr-2 fa {{ filter.icon }}"></i>
{{ filter.name }}
</button>
{% endfor %}
<!-- Enhanced Loading Indicator -->
<div class="loading-indicator htmx-indicator">
<svg viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Updating results...</span>
<span class="loading-timestamp text-xs opacity-75"></span>
</div>
<div>
<label for="category" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Category</label>
</div>
<!-- Filters -->
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800" x-data="{ showAdvanced: localStorage.getItem('showAdvancedFilters') === 'true' }">
<!-- Active Filters -->
{% if current_filters.search or current_filters.category or current_filters.status %}
<div class="flex flex-wrap items-center gap-2 mb-4 -mt-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Active Filters:</span>
{% for name, value in current_filters.items %}
{% if value %}
<button
hx-get="{% if park %}{% url 'parks:rides:ride_list' park.slug %}{% else %}{% url 'rides:global_ride_list' %}{% endif %}"
hx-include="form"
hx-target="#rides-grid"
hx-push-url="true"
@click="$dispatch('clear-filter', '{{ name }}')"
class="inline-flex items-center px-2 py-1 text-sm bg-blue-100 border border-blue-200 rounded-full dark:bg-blue-900 dark:border-blue-800">
<span class="mr-1">{{ name|title }}: {{ value }}</span>
<i class="text-gray-500 fa fa-times dark:text-gray-400"></i>
</button>
{% endif %}
{% endfor %}
</div>
{% endif %}
<form class="grid grid-cols-1 gap-4"
x-data
hx-get="{% if park %}{% url 'parks:rides:ride_list' park.slug %}{% else %}{% url 'rides:global_ride_list' %}{% endif %}"
x-init="$watch('showAdvanced', value => { if (!value) window.scrollTo({ top: 0, behavior: 'smooth' }) })"
hx-trigger="change from:select, input[type='number'] delay:500ms, input[type='text'] changed delay:300ms, search"
hx-target="#rides-grid"
hx-push-url="true"
hx-indicator=".htmx-indicator"
@reset.prevent="$dispatch('clear-filters'); localStorage.removeItem('rideFilters')"
@clear-filters.window="$el.reset(); htmx.trigger(this, 'change')"
@filter-changed.window="htmx.trigger(this, 'change')">
<!-- Search Input -->
<div class="md:col-span-2">
<div x-data="rideSearch" class="relative">
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Search by name, park, or description
</label>
<input type="text"
name="search"
id="search"
x-model="searchQuery"
@input="handleInput"
@focus="handleInput"
@keydown="handleKeydown"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Example: steel coaster at universal"
:value="searchQuery"
x-init="searchQuery = '{{ current_filters.search }}'"
autocomplete="off">
<div class="absolute w-full mt-1 bg-white rounded-lg shadow-lg dark:bg-gray-800"
id="search-suggestions"
x-show="showSuggestions"
x-cloak>
</div>
</div>
</div>
<!-- Basic Filters -->
<div class="md:col-span-2">
<label for="category" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Ride Type</label>
<select name="category" id="category"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Categories</option>
@@ -64,8 +136,9 @@
<option value="OT" {% if current_filters.category == 'OT' %}selected{% endif %}>Other</option>
</select>
</div>
<div>
<label for="status" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<div class="md:col-span-2">
<label for="status" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Operating Status</label>
<select name="status" id="status"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Statuses</option>
@@ -77,103 +150,69 @@
<option value="RELOCATED" {% if current_filters.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
</select>
</div>
</form>
<!-- Advanced Filters Toggle -->
<div class="flex items-center justify-between md:col-span-6">
<button type="button"
@click="showAdvanced = !showAdvanced; localStorage.setItem('showAdvancedFilters', showAdvanced)"
class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<span x-text="showAdvanced ? 'Hide Advanced Filters' : 'Show Advanced Filters'"></span>
<i class="ml-1 fa" :class="showAdvanced ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
</button>
</div>
<!-- Rides Grid -->
<div id="rides-grid" class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for ride in rides %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div class="aspect-w-16 aspect-h-9">
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
{% if ride.photos.exists %}
<img src="{{ ride.photos.first.image.url }}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% else %}
<img src="{% get_ride_placeholder_image ride.category %}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% endif %}
</a>
</div>
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
</a>
</h2>
{% if not park %}
<p class="mb-3 text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
</p>
{% endif %}
<div class="flex flex-wrap gap-2">
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
{{ ride.get_category_display }}
</span>
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% if ride.average_rating %}
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
{% if ride.coaster_stats %}
<div class="grid grid-cols-2 gap-2 mt-4">
{% if ride.coaster_stats.height_ft %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Height: {{ ride.coaster_stats.height_ft }}ft
</div>
{% endif %}
{% if ride.coaster_stats.speed_mph %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Speed: {{ ride.coaster_stats.speed_mph }}mph
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-span-3 py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No rides found matching your criteria.</p>
</div>
<!-- Advanced Filters -->
<div x-show="showAdvanced"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
class="grid grid-cols-1 gap-4 md:grid-cols-6 md:col-span-6">
<div class="md:col-span-2">
<label for="manufacturer" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</label>
<select name="manufacturer" id="manufacturer"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Manufacturers</option>
{% for mfr in manufacturers %}
<option value="{{ mfr.id }}" {% if current_filters.manufacturer == mfr.id %}selected{% endif %}>
{{ mfr.name }}
</option>
{% endfor %}
</select>
</div>
<div class="md:col-span-2">
<label for="track_material" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Track Material</label>
<select name="track_material" id="track_material"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Materials</option>
<option value="STEEL" {% if current_filters.track_material == 'STEEL' %}selected{% endif %}>Steel</option>
<option value="WOOD" {% if current_filters.track_material == 'WOOD' %}selected{% endif %}>Wood</option>
<option value="HYBRID" {% if current_filters.track_material == 'HYBRID' %}selected{% endif %}>Hybrid</option>
</select>
</div>
<div class="md:col-span-2">
<label for="coaster_type" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Coaster Type</label>
<select name="coaster_type" id="coaster_type"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Types</option>
<option value="SITDOWN" {% if current_filters.coaster_type == 'SITDOWN' %}selected{% endif %}>Sit Down</option>
<option value="INVERTED" {% if current_filters.coaster_type == 'INVERTED' %}selected{% endif %}>Inverted</option>
<option value="FLYING" {% if current_filters.coaster_type == 'FLYING' %}selected{% endif %}>Flying</option>
<option value="STANDUP" {% if current_filters.coaster_type == 'STANDUP' %}selected{% endif %}>Stand Up</option>
<option value="WING" {% if current_filters.coaster_type == 'WING' %}selected{% endif %}>Wing</option>
<option value="DIVE" {% if current_filters.coaster_type == 'DIVE' %}selected{% endif %}>Dive</option>
<option value="FAMILY" {% if current_filters.coaster_type == 'FAMILY' %}selected{% endif %}>Family</option>
<option value="WILD_MOUSE" {% if current_filters.coaster_type == 'WILD_MOUSE' %}selected{% endif %}>Wild Mouse</option>
<option value="SPINNING" {% if current_filters.coaster_type == 'SPINNING' %}selected{% endif %}>Spinning</option>
<option value="FOURTH_DIMENSION" {% if current_filters.coaster_type == 'FOURTH_DIMENSION' %}selected{% endif %}>4th Dimension</option>
</select>
</div>
<!-- Pagination -->
{% if is_paginated %}
<div class="flex justify-center mt-6">
<div class="inline-flex rounded-md shadow-sm">
{% if page_obj.has_previous %}
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">&laquo; First</a>
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
{% endif %}
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last &raquo;</a>
{% endif %}
<!-- Stats Filters -->
<div class="md:col-span-2">
<label for="min_height" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Min Height (ft)</label>
<input type="number" name="min_height" id="min_height"
value="{{ current_filters.min_height }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white">
</div>
</div>
{% endif %}
</div>
{% endblock %}
<div class="md:col-span-2">