mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 13:11:12 -05:00
Add search suggestions feature with category filtering and display; enhance ride list view with improved search functionality
This commit is contained in:
26
rides/templates/rides/partials/search_suggestions.html
Normal file
26
rides/templates/rides/partials/search_suggestions.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% if suggestions %}
|
||||
<div id="search-suggestions" class="search-suggestions">
|
||||
{% for suggestion in suggestions %}
|
||||
<div class="suggestion"
|
||||
data-type="{{ suggestion.type }}"
|
||||
data-suggestion="{{ suggestion.text|escape }}"
|
||||
role="option">
|
||||
{% if suggestion.type == 'ride' %}
|
||||
<span class="icon">🎢</span>
|
||||
<span class="text">{{ suggestion.text }}</span>
|
||||
<span class="count">({{ suggestion.count }} rides)</span>
|
||||
{% elif suggestion.type == 'park' %}
|
||||
<span class="icon">🎪</span>
|
||||
<span class="text">{{ suggestion.text }}</span>
|
||||
{% if suggestion.location %}
|
||||
<span class="location">{{ suggestion.location }}</span>
|
||||
{% endif %}
|
||||
{% elif suggestion.type == 'category' %}
|
||||
<span class="icon">📂</span>
|
||||
<span class="text">{{ suggestion.text }}</span>
|
||||
<span class="count">({{ suggestion.count }} rides)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
214
rides/templates/rides/ride_list.html
Normal file
214
rides/templates/rides/ride_list.html
Normal file
@@ -0,0 +1,214 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
{% load ride_tags %}
|
||||
|
||||
{% block title %}
|
||||
{% if park %}
|
||||
Rides at {{ park.name }} - ThrillWiki
|
||||
{% else %}
|
||||
All Rides - ThrillWiki
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-4">
|
||||
{% if park %}
|
||||
Rides at {{ park.name }}
|
||||
{% else %}
|
||||
All Rides
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
{# Search Section #}
|
||||
<div class="relative">
|
||||
<div class="flex items-center">
|
||||
<input type="text"
|
||||
id="ride-search"
|
||||
name="q"
|
||||
class="w-full p-4 border rounded-lg shadow-sm"
|
||||
placeholder="Search rides by name, park, or category..."
|
||||
hx-get="."
|
||||
hx-trigger="keyup changed delay:500ms, search"
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-loading"
|
||||
autocomplete="off">
|
||||
|
||||
<div id="search-loading" class="loading-indicator htmx-indicator">
|
||||
<i class="ml-3 fa fa-spinner fa-spin text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Search Suggestions #}
|
||||
<div id="search-suggestions-wrapper"
|
||||
hx-get="{% url 'rides:search_suggestions' %}"
|
||||
hx-trigger="keyup from:#ride-search delay:200ms"
|
||||
class="absolute w-full bg-white border rounded-lg shadow-lg mt-1 z-50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Quick Filter Buttons #}
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
<button class="filter-btn active"
|
||||
hx-get="."
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
hx-include="[name='q']">
|
||||
All Rides
|
||||
</button>
|
||||
<button class="filter-btn"
|
||||
hx-get="."
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
hx-include="[name='q']"
|
||||
hx-vals='{"operating": "true"}'>
|
||||
Operating
|
||||
</button>
|
||||
{% for code, name in category_choices %}
|
||||
<button class="filter-btn"
|
||||
hx-get="."
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
hx-include="[name='q']"
|
||||
hx-vals='{"category": "{{ code }}"}'>
|
||||
{{ name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Active Filter Tags #}
|
||||
<div id="active-filters" class="flex flex-wrap gap-2 mt-4">
|
||||
{% if request.GET.q %}
|
||||
<span class="filter-tag">
|
||||
Search: {{ request.GET.q }}
|
||||
<button class="ml-2"
|
||||
hx-get="."
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
title="Clear search">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if request.GET.category %}
|
||||
<span class="filter-tag">
|
||||
Category: {{ request.GET.category|get_category_display }}
|
||||
<button class="ml-2"
|
||||
hx-get="."
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
hx-include="[name='q']"
|
||||
title="Clear category">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if request.GET.operating %}
|
||||
<span class="filter-tag">
|
||||
Operating Only
|
||||
<button class="ml-2"
|
||||
hx-get="."
|
||||
hx-target="#ride-list-results"
|
||||
hx-push-url="true"
|
||||
hx-include="[name='q']"
|
||||
title="Clear operating filter">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Results Section #}
|
||||
<div id="ride-list-results" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% include "rides/partials/ride_list_results.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle suggestion selection
|
||||
document.addEventListener('click', function(e) {
|
||||
const suggestion = e.target.closest('.suggestion');
|
||||
if (suggestion) {
|
||||
const searchInput = document.getElementById('ride-search');
|
||||
searchInput.value = suggestion.dataset.suggestion;
|
||||
searchInput.dispatchEvent(new Event('keyup')); // Trigger HTMX search
|
||||
document.getElementById('search-suggestions-wrapper').innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle filter button UI
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize active state based on URL params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const category = urlParams.get('category');
|
||||
const operating = urlParams.get('operating');
|
||||
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
const btnVals = btn.getAttribute('hx-vals');
|
||||
if (
|
||||
(category && btnVals && btnVals.includes(category)) ||
|
||||
(operating && btnVals && btnVals.includes('operating')) ||
|
||||
(!category && !operating && btn.textContent.trim() === 'All Rides')
|
||||
) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.filter-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.filter-tag button {
|
||||
color: #6b7280;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-tag button:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .loading-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,6 @@
|
||||
from django import template
|
||||
from django.templatetags.static import static
|
||||
from ..models import CATEGORY_CHOICES
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -22,3 +23,9 @@ def get_ride_placeholder_image(category):
|
||||
def get_park_placeholder_image():
|
||||
"""Return placeholder image for parks"""
|
||||
return static("images/placeholders/default-park.jpg")
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_category_display(code):
|
||||
"""Convert category code to display name"""
|
||||
return dict(CATEGORY_CHOICES).get(code, code)
|
||||
|
||||
@@ -219,25 +219,55 @@ class RideListView(ListView):
|
||||
context_object_name = 'rides'
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get all rides or filter by park if park_slug is provided"""
|
||||
"""Get filtered rides based on search and filters"""
|
||||
queryset = Ride.objects.all().select_related(
|
||||
'park',
|
||||
'ride_model',
|
||||
'ride_model__manufacturer'
|
||||
).prefetch_related('photos')
|
||||
|
||||
# Park filter
|
||||
if 'park_slug' in self.kwargs:
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
|
||||
queryset = queryset.filter(park=self.park)
|
||||
|
||||
# Search term handling
|
||||
search = self.request.GET.get('q', '').strip()
|
||||
if search:
|
||||
# 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
|
||||
|
||||
queryset = queryset.filter(search_query)
|
||||
|
||||
# Category filter
|
||||
category = self.request.GET.get('category')
|
||||
if category and category != 'all':
|
||||
queryset = queryset.filter(category=category)
|
||||
|
||||
# Operating status filter
|
||||
if self.request.GET.get('operating') == 'true':
|
||||
queryset = queryset.filter(status='operating')
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add park to context if park_slug is provided"""
|
||||
"""Add park and category choices 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_choices'] = CATEGORY_CHOICES
|
||||
return context
|
||||
|
||||
|
||||
@@ -336,3 +366,63 @@ def search_ride_models(request: HttpRequest) -> HttpResponse:
|
||||
{"ride_models": ride_models, "search_term": query,
|
||||
"manufacturer_id": manufacturer_id},
|
||||
)
|
||||
|
||||
|
||||
def get_search_suggestions(request: HttpRequest) -> HttpResponse:
|
||||
"""Get smart search suggestions for rides
|
||||
|
||||
Returns suggestions including:
|
||||
- Common matching ride names
|
||||
- Matching parks
|
||||
- Matching categories
|
||||
"""
|
||||
query = request.GET.get('q', '').strip().lower()
|
||||
suggestions = []
|
||||
|
||||
if query:
|
||||
# Get common ride names
|
||||
matching_names = Ride.objects.filter(
|
||||
name__icontains=query
|
||||
).values('name').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')[:3]
|
||||
|
||||
for match in matching_names:
|
||||
suggestions.append({
|
||||
'type': 'ride',
|
||||
'text': match['name'],
|
||||
'count': match['count']
|
||||
})
|
||||
|
||||
# Get matching parks
|
||||
matching_parks = Park.objects.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(location__city__icontains=query)
|
||||
)[:3]
|
||||
|
||||
for park in matching_parks:
|
||||
suggestions.append({
|
||||
'type': 'park',
|
||||
'text': park.name,
|
||||
'location': park.location.city if park.location else None
|
||||
})
|
||||
|
||||
# Add category matches
|
||||
for code, name in CATEGORY_CHOICES:
|
||||
if query in name.lower():
|
||||
ride_count = Ride.objects.filter(category=code).count()
|
||||
suggestions.append({
|
||||
'type': 'category',
|
||||
'code': code,
|
||||
'text': name,
|
||||
'count': ride_count
|
||||
})
|
||||
|
||||
return render(
|
||||
request,
|
||||
'rides/partials/search_suggestions.html',
|
||||
{
|
||||
'suggestions': suggestions,
|
||||
'query': query
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user