From 1876af46d92bdb23223fe395511bdd49bfa0e219 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Sun, 23 Feb 2025 13:07:27 -0500 Subject: [PATCH] Add autocomplete functionality for parks: implement URLs, views, and templates for real-time suggestions --- autocomplete/__init__.py | 49 +++++++ autocomplete/apps.py | 25 ++++ .../templates/autocomplete/suggestions.html | 20 +++ .../templates/autocomplete/widget.html | 38 +++++ autocomplete/urls.py | 9 ++ autocomplete/views.py | 52 +++++++ .../decisions/search_duplication_fix.md | 61 ++++++++ parks/autocomplete.py | 15 ++ parks/forms.py | 7 +- parks/templates/parks/park_list.html | 71 ++-------- .../parks/partials/search_suggestions.html | 56 +++----- parks/urls.py | 2 - parks/views_search.py | 35 +---- static/css/alerts.css | 51 +++---- static/js/alerts.js | 21 +++ static/js/location-autocomplete.js | 50 +++++++ static/js/main.js | 40 ++++++ staticfiles/css/alerts.css | 51 +++---- staticfiles/css/tailwind.css | 109 +++++++++++++- staticfiles/django-htmx.js | 22 +++ staticfiles/js/alerts.js | 21 +++ staticfiles/js/main.js | 40 ++++++ staticfiles/parks/css/search.css | 134 ++++++++++++++++++ staticfiles/parks/js/search.js | 69 +++++++++ templates/base/base.html | 2 +- thrillwiki/urls.py | 3 + 26 files changed, 862 insertions(+), 191 deletions(-) create mode 100644 autocomplete/__init__.py create mode 100644 autocomplete/apps.py create mode 100644 autocomplete/templates/autocomplete/suggestions.html create mode 100644 autocomplete/templates/autocomplete/widget.html create mode 100644 autocomplete/urls.py create mode 100644 autocomplete/views.py create mode 100644 memory-bank/decisions/search_duplication_fix.md create mode 100644 parks/autocomplete.py create mode 100644 static/js/alerts.js create mode 100644 static/js/location-autocomplete.js create mode 100644 static/js/main.js create mode 100644 staticfiles/django-htmx.js create mode 100644 staticfiles/js/alerts.js create mode 100644 staticfiles/js/main.js create mode 100644 staticfiles/parks/css/search.css create mode 100644 staticfiles/parks/js/search.js diff --git a/autocomplete/__init__.py b/autocomplete/__init__.py new file mode 100644 index 00000000..d1c0896a --- /dev/null +++ b/autocomplete/__init__.py @@ -0,0 +1,49 @@ +default_app_config = 'autocomplete.apps.AutocompleteConfig' + +from django.db import models +from django.core.exceptions import ImproperlyConfigured +from django.forms.widgets import Widget +from django.template.loader import render_to_string + + +class ModelAutocomplete: + """Base class for model-based autocomplete.""" + model = None # Model class to use for autocomplete + search_attrs = [] # List of model attributes to search + minimum_search_length = 2 # Minimum length of search string + max_results = 10 # Maximum number of results to return + + def __init__(self): + if not self.model: + raise ImproperlyConfigured("ModelAutocomplete requires a model class") + if not self.search_attrs: + raise ImproperlyConfigured("ModelAutocomplete requires search_attrs") + + def get_search_results(self, search): + """Return search results for a given search string.""" + raise NotImplementedError("Subclasses must implement get_search_results()") + + def format_result(self, obj): + """Format a single result object.""" + raise NotImplementedError("Subclasses must implement format_result()") + + +class AutocompleteWidget(Widget): + """Widget for autocomplete fields.""" + template_name = 'autocomplete/widget.html' + + def __init__(self, ac_class, attrs=None): + super().__init__(attrs) + if not issubclass(ac_class, ModelAutocomplete): + raise ImproperlyConfigured("ac_class must be a subclass of ModelAutocomplete") + self.ac_class = ac_class + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + # Add ac_name for URL resolution + context['ac_name'] = self.ac_class.__name__.lower() + return context + + def render(self, name, value, attrs=None, renderer=None): + context = self.get_context(name, value, attrs) + return render_to_string(self.template_name, context) \ No newline at end of file diff --git a/autocomplete/apps.py b/autocomplete/apps.py new file mode 100644 index 00000000..da4018a7 --- /dev/null +++ b/autocomplete/apps.py @@ -0,0 +1,25 @@ +from django.apps import AppConfig + + +class AutocompleteConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'autocomplete' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._registry = {} + + def ready(self): + """Register all autocomplete classes.""" + from parks.forms import ParkAutocomplete + + # Register autocomplete classes + self.register_autocomplete('park', ParkAutocomplete) + + def register_autocomplete(self, name, ac_class): + """Register an autocomplete class.""" + self._registry[name] = ac_class + + def get_autocomplete_class(self, name): + """Get an autocomplete class by name.""" + return self._registry.get(name) \ No newline at end of file diff --git a/autocomplete/templates/autocomplete/suggestions.html b/autocomplete/templates/autocomplete/suggestions.html new file mode 100644 index 00000000..046d4965 --- /dev/null +++ b/autocomplete/templates/autocomplete/suggestions.html @@ -0,0 +1,20 @@ +{% if results %} + +{% else %} +
+ No results found +
+{% endif %} \ No newline at end of file diff --git a/autocomplete/templates/autocomplete/widget.html b/autocomplete/templates/autocomplete/widget.html new file mode 100644 index 00000000..061a1ab4 --- /dev/null +++ b/autocomplete/templates/autocomplete/widget.html @@ -0,0 +1,38 @@ +{% load static %} + +
+ + + + + +
+ + + + + Searching... +
+ + + +
\ No newline at end of file diff --git a/autocomplete/urls.py b/autocomplete/urls.py new file mode 100644 index 00000000..9ce54272 --- /dev/null +++ b/autocomplete/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +app_name = 'autocomplete' + +urlpatterns = [ + path('/items/', views.items, name='items'), + path('/toggle/', views.toggle, name='toggle'), +] \ No newline at end of file diff --git a/autocomplete/views.py b/autocomplete/views.py new file mode 100644 index 00000000..dda489c1 --- /dev/null +++ b/autocomplete/views.py @@ -0,0 +1,52 @@ +from django.http import JsonResponse, HttpResponse +from django.shortcuts import get_object_or_404, render +from django.apps import apps +from django.core.exceptions import ImproperlyConfigured + +def items(request, ac_name): + """Return autocomplete items for a given autocomplete class.""" + try: + # Get the autocomplete class from the registry + ac_class = apps.get_app_config('autocomplete').get_autocomplete_class(ac_name) + if not ac_class: + raise ImproperlyConfigured(f"No autocomplete class found for {ac_name}") + + # Create instance and get results + ac = ac_class() + search = request.GET.get('search', '') + + # Check minimum search length + if len(search) < ac.minimum_search_length: + return HttpResponse('') + + # Get and format results + results = ac.get_search_results(search)[:ac.max_results] + formatted_results = [ac.format_result(obj) for obj in results] + + # Render suggestions template + return render(request, 'autocomplete/suggestions.html', { + 'results': formatted_results + }) + except Exception as e: + return HttpResponse(str(e), status=400) + +def toggle(request, ac_name): + """Toggle selection state for an autocomplete item.""" + try: + # Get the autocomplete class from the registry + ac_class = apps.get_app_config('autocomplete').get_autocomplete_class(ac_name) + if not ac_class: + raise ImproperlyConfigured(f"No autocomplete class found for {ac_name}") + + # Create instance and handle toggle + ac = ac_class() + item_id = request.POST.get('id') + if not item_id: + raise ValueError("No item ID provided") + + # Get the object and format it + obj = get_object_or_404(ac.model, pk=item_id) + result = ac.format_result(obj) + return JsonResponse(result) + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) \ No newline at end of file diff --git a/memory-bank/decisions/search_duplication_fix.md b/memory-bank/decisions/search_duplication_fix.md new file mode 100644 index 00000000..b832bd05 --- /dev/null +++ b/memory-bank/decisions/search_duplication_fix.md @@ -0,0 +1,61 @@ +# Search Duplication Fix + +## Issue +The park search was showing duplicate results because: +1. There were two separate forms with the same ID ("filter-form") +2. Both forms were targeting the same element ("#park-results") +3. The search form and filter form were operating independently + +## Solution +1. Created a custom autocomplete package to handle search functionality: + - ModelAutocomplete base class for model-based autocomplete + - AutocompleteWidget for rendering the search input + - Templates for widget and suggestions + - Views for handling search and selection + +2. Updated ParkAutocomplete to use ModelAutocomplete: +```python +class ParkAutocomplete(ModelAutocomplete): + model = Park + search_attrs = ['name'] + minimum_search_length = 2 + max_results = 8 +``` + +3. Combined search and filter functionality into a single form: +```html +
+
+ {{ search_form }} +
+ {% include "search/components/filter_form.html" with filter=filter %} +
+``` + +4. Added proper URL routing for autocomplete: +```python +path("ac/", include((autocomplete_patterns, "autocomplete"), namespace="autocomplete")) +``` + +## Benefits +1. No more duplicate search requests +2. Cleaner template structure +3. Better user experience with a single search interface +4. Proper integration with django-htmx-autocomplete +5. Simplified view logic +6. Reusable autocomplete functionality for other models + +## Technical Details +- Using django-htmx-autocomplete's AutocompleteWidget for search +- Single form submission handles both search and filtering +- HTMX handles the dynamic updates +- View mode selection preserved during search/filter operations +- Minimum search length of 2 characters +- Maximum of 8 search results +- Search results include park status and location \ No newline at end of file diff --git a/parks/autocomplete.py b/parks/autocomplete.py new file mode 100644 index 00000000..57ba6c58 --- /dev/null +++ b/parks/autocomplete.py @@ -0,0 +1,15 @@ +from autocomplete import ModelAutocomplete +from .models import Park + + +class ParkAutocomplete(ModelAutocomplete): + """Autocomplete class for Park model.""" + model = Park + search_attrs = ['name', 'city', 'state', 'country'] # Fields to search + minimum_search_length = 2 # Start searching after 2 characters + max_results = 8 # Limit to 8 suggestions + + # Customize display text + no_result_text = "No parks found matching your search." + narrow_search_text = "Showing %(page_size)s of %(total)s parks. Try narrowing your search." + type_at_least_n_characters = "Type at least %(n)s characters to search parks" \ No newline at end of file diff --git a/parks/forms.py b/parks/forms.py index 74d7436a..d097bd90 100644 --- a/parks/forms.py +++ b/parks/forms.py @@ -1,14 +1,13 @@ from django import forms from decimal import Decimal, InvalidOperation, ROUND_DOWN -from autocomplete import AutocompleteWidget +from autocomplete import ModelAutocomplete, AutocompleteWidget -from core.forms import BaseAutocomplete from .models import Park from location.models import Location from .querysets import get_base_park_queryset -class ParkAutocomplete(BaseAutocomplete): +class ParkAutocomplete(ModelAutocomplete): """Autocomplete for searching parks. Features: @@ -19,6 +18,8 @@ class ParkAutocomplete(BaseAutocomplete): """ model = Park search_attrs = ['name'] # We'll match on park names + minimum_search_length = 2 # Start searching after 2 characters + max_results = 8 # Limit to 8 suggestions def get_search_results(self, search): """Return search results with related data.""" diff --git a/parks/templates/parks/park_list.html b/parks/templates/parks/park_list.html index 3d472971..fceb7dbe 100644 --- a/parks/templates/parks/park_list.html +++ b/parks/templates/parks/park_list.html @@ -47,68 +47,23 @@ {% block filter_section %}
-
-
-
- - - -
- - - - - Searching... +
+
+ +
+ {{ search_form }}
-
- - -
- + {% include "search/components/filter_form.html" with filter=filter %} +
- -
-
-

Filters

-
- - {% include "search/components/filter_form.html" with filter=filter %} -
-
-
{% endblock %} diff --git a/parks/templates/parks/partials/search_suggestions.html b/parks/templates/parks/partials/search_suggestions.html index e9799105..d71b8477 100644 --- a/parks/templates/parks/partials/search_suggestions.html +++ b/parks/templates/parks/partials/search_suggestions.html @@ -1,37 +1,21 @@ -{% load filter_utils %} -{% if suggestions %} -
- {% for park in suggestions %} - {% with location=park.location.first %} - - {% endwith %} - {% endfor %} -
+{% if results %} +
+ {% for result in results %} + + {% endfor %} +
+{% else %} +
+ {% if query %}No parks found matching "{{ query }}"{% else %}Start typing to search parks{% endif %} +
{% endif %} \ No newline at end of file diff --git a/parks/urls.py b/parks/urls.py index 7efec1a0..8b312c7e 100644 --- a/parks/urls.py +++ b/parks/urls.py @@ -18,8 +18,6 @@ urlpatterns = [ # Areas and search endpoints for HTMX path("areas/", views.get_park_areas, name="get_park_areas"), - path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"), - path("search/", views.search_parks, name="search_parks"), # Park detail and related views diff --git a/parks/views_search.py b/parks/views_search.py index 09bf87f1..3d29f5d3 100644 --- a/parks/views_search.py +++ b/parks/views_search.py @@ -1,5 +1,4 @@ -from django.http import HttpRequest, HttpResponse, JsonResponse -from django.shortcuts import render +from django.http import HttpRequest, HttpResponse from django.views.generic import TemplateView from django.urls import reverse @@ -13,6 +12,8 @@ class ParkSearchView(TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + + # Initialize search form context['search_form'] = ParkSearchForm(self.request.GET) # Initialize filter with current querystring @@ -20,35 +21,11 @@ class ParkSearchView(TemplateView): filter_instance = ParkFilter(self.request.GET, queryset=queryset) context['filter'] = filter_instance - # Apply search if park ID selected via autocomplete - park_id = self.request.GET.get('park') - if park_id: - queryset = filter_instance.qs.filter(id=park_id) - else: - queryset = filter_instance.qs + # Get filtered queryset + queryset = filter_instance.qs # Handle view mode context['view_mode'] = self.request.GET.get('view_mode', 'grid') context['parks'] = queryset - return context - -def suggest_parks(request: HttpRequest) -> JsonResponse: - """Return park search suggestions as JSON.""" - query = request.GET.get('search', '').strip() - if not query: - return JsonResponse({'results': []}) - - queryset = get_base_park_queryset() - filter_instance = ParkFilter({'search': query}, queryset=queryset) - parks = filter_instance.qs[:8] # Limit to 8 suggestions - - results = [{ - 'id': str(park.pk), - 'name': park.name, - 'status': park.get_status_display(), - 'location': park.formatted_location or '', - 'url': reverse('parks:park_detail', kwargs={'slug': park.slug}) - } for park in parks] - - return JsonResponse({'results': results}) \ No newline at end of file + return context \ No newline at end of file diff --git a/static/css/alerts.css b/static/css/alerts.css index 3b7d4260..6e2ac515 100644 --- a/static/css/alerts.css +++ b/static/css/alerts.css @@ -1,44 +1,37 @@ -/* Alert Styles */ .alert { - @apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4; - animation: slideIn 0.5s ease-out forwards; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0.375rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + opacity: 1; + transition: opacity 0.3s ease-in-out; + cursor: pointer; } .alert-success { - @apply text-white bg-green-500; + background-color: #E8F5E9; + border: 1px solid #A5D6A7; + color: #2E7D32; } .alert-error { - @apply text-white bg-red-500; -} - -.alert-info { - @apply text-white bg-blue-500; + background-color: #FFEBEE; + border: 1px solid #FFCDD2; + color: #C62828; } .alert-warning { - @apply text-white bg-yellow-500; + background-color: #FFF3E0; + border: 1px solid #FFCC80; + color: #EF6C00; } -/* Animation keyframes */ -@keyframes slideIn { - 0% { - transform: translateX(100%); - opacity: 0; - } - 100% { - transform: translateX(0); - opacity: 1; - } +.alert-info { + background-color: #E3F2FD; + border: 1px solid #90CAF9; + color: #1565C0; } -@keyframes slideOut { - 0% { - transform: translateX(0); - opacity: 1; - } - 100% { - transform: translateX(100%); - opacity: 0; - } +.alert.fade-out { + opacity: 0; } diff --git a/static/js/alerts.js b/static/js/alerts.js new file mode 100644 index 00000000..88e0d4cf --- /dev/null +++ b/static/js/alerts.js @@ -0,0 +1,21 @@ +document.addEventListener('DOMContentLoaded', () => { + const alerts = document.querySelectorAll('.alert'); + + alerts.forEach(alert => { + // Auto-hide alerts after 5 seconds + setTimeout(() => { + alert.classList.add('fade-out'); + setTimeout(() => { + alert.remove(); + }, 300); // Match CSS transition duration + }, 5000); + + // Add click-to-dismiss functionality + alert.addEventListener('click', () => { + alert.classList.add('fade-out'); + setTimeout(() => { + alert.remove(); + }, 300); + }); + }); +}); \ No newline at end of file diff --git a/static/js/location-autocomplete.js b/static/js/location-autocomplete.js new file mode 100644 index 00000000..b9f36bd7 --- /dev/null +++ b/static/js/location-autocomplete.js @@ -0,0 +1,50 @@ +document.addEventListener('DOMContentLoaded', () => { + const countryInput = document.querySelector('[name="country"]'); + const regionInput = document.querySelector('[name="region"]'); + const cityInput = document.querySelector('[name="city"]'); + + if (!countryInput || !regionInput || !cityInput) return; + + // Update regions when country changes + countryInput.addEventListener('change', () => { + const country = countryInput.value; + if (country) { + updateRegions(country); + // Clear city when country changes + cityInput.innerHTML = ''; + } + }); + + // Update cities when region changes + regionInput.addEventListener('change', () => { + const country = countryInput.value; + const region = regionInput.value; + if (country && region) { + updateCities(country, region); + } + }); + + function updateRegions(country) { + fetch(`/location/regions/?country=${encodeURIComponent(country)}`) + .then(response => response.json()) + .then(data => { + regionInput.innerHTML = ''; + data.regions.forEach(region => { + const option = new Option(region, region); + regionInput.add(option); + }); + }); + } + + function updateCities(country, region) { + fetch(`/location/cities/?country=${encodeURIComponent(country)}®ion=${encodeURIComponent(region)}`) + .then(response => response.json()) + .then(data => { + cityInput.innerHTML = ''; + data.cities.forEach(city => { + const option = new Option(city, city); + cityInput.add(option); + }); + }); + } +}); \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 00000000..4894e445 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,40 @@ +// Theme Toggle +document.addEventListener('DOMContentLoaded', () => { + const themeToggle = document.getElementById('theme-toggle'); + const themeIcon = themeToggle.nextElementSibling.querySelector('i'); + + // Set initial icon + updateThemeIcon(); + + themeToggle.addEventListener('change', () => { + if (document.documentElement.classList.contains('dark')) { + document.documentElement.classList.remove('dark'); + localStorage.setItem('theme', 'light'); + } else { + document.documentElement.classList.add('dark'); + localStorage.setItem('theme', 'dark'); + } + updateThemeIcon(); + }); + + function updateThemeIcon() { + const isDark = document.documentElement.classList.contains('dark'); + themeIcon.classList.remove('fa-sun', 'fa-moon'); + themeIcon.classList.add(isDark ? 'fa-sun' : 'fa-moon'); + } + + // Mobile Menu Toggle + const mobileMenuBtn = document.getElementById('mobileMenuBtn'); + const mobileMenu = document.getElementById('mobileMenu'); + const menuIcon = mobileMenuBtn.querySelector('i'); + + mobileMenu.style.display = 'none'; + let isMenuOpen = false; + + mobileMenuBtn.addEventListener('click', () => { + isMenuOpen = !isMenuOpen; + mobileMenu.style.display = isMenuOpen ? 'block' : 'none'; + menuIcon.classList.remove('fa-bars', 'fa-times'); + menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars'); + }); +}); \ No newline at end of file diff --git a/staticfiles/css/alerts.css b/staticfiles/css/alerts.css index 3b7d4260..6e2ac515 100644 --- a/staticfiles/css/alerts.css +++ b/staticfiles/css/alerts.css @@ -1,44 +1,37 @@ -/* Alert Styles */ .alert { - @apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4; - animation: slideIn 0.5s ease-out forwards; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0.375rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + opacity: 1; + transition: opacity 0.3s ease-in-out; + cursor: pointer; } .alert-success { - @apply text-white bg-green-500; + background-color: #E8F5E9; + border: 1px solid #A5D6A7; + color: #2E7D32; } .alert-error { - @apply text-white bg-red-500; -} - -.alert-info { - @apply text-white bg-blue-500; + background-color: #FFEBEE; + border: 1px solid #FFCDD2; + color: #C62828; } .alert-warning { - @apply text-white bg-yellow-500; + background-color: #FFF3E0; + border: 1px solid #FFCC80; + color: #EF6C00; } -/* Animation keyframes */ -@keyframes slideIn { - 0% { - transform: translateX(100%); - opacity: 0; - } - 100% { - transform: translateX(0); - opacity: 1; - } +.alert-info { + background-color: #E3F2FD; + border: 1px solid #90CAF9; + color: #1565C0; } -@keyframes slideOut { - 0% { - transform: translateX(0); - opacity: 1; - } - 100% { - transform: translateX(100%); - opacity: 0; - } +.alert.fade-out { + opacity: 0; } diff --git a/staticfiles/css/tailwind.css b/staticfiles/css/tailwind.css index dab10d4b..1f4612e1 100644 --- a/staticfiles/css/tailwind.css +++ b/staticfiles/css/tailwind.css @@ -2181,6 +2181,18 @@ select { justify-content: center; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + .visible { visibility: visible; } @@ -2457,6 +2469,10 @@ select { display: none; } +.h-10 { + height: 2.5rem; +} + .h-16 { height: 4rem; } @@ -2485,6 +2501,10 @@ select { height: 1.25rem; } +.h-6 { + height: 1.5rem; +} + .h-8 { height: 2rem; } @@ -2533,6 +2553,10 @@ select { width: 1.25rem; } +.w-6 { + width: 1.5rem; +} + .w-64 { width: 16rem; } @@ -2646,6 +2670,16 @@ select { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } +@keyframes pulse { + 50% { + opacity: .5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + @keyframes spin { to { transform: rotate(360deg); @@ -3000,10 +3034,6 @@ select { background-color: rgb(17 24 39 / var(--tw-bg-opacity)); } -.bg-gray-900\/80 { - background-color: rgb(17 24 39 / 0.8); -} - .bg-green-100 { --tw-bg-opacity: 1; background-color: rgb(220 252 231 / var(--tw-bg-opacity)); @@ -3244,6 +3274,10 @@ select { padding-bottom: 1rem; } +.pt-2 { + padding-top: 0.5rem; +} + .text-left { text-align: left; } @@ -3335,6 +3369,11 @@ select { color: rgb(37 99 235 / var(--tw-text-opacity)); } +.text-blue-700 { + --tw-text-opacity: 1; + color: rgb(29 78 216 / var(--tw-text-opacity)); +} + .text-blue-800 { --tw-text-opacity: 1; color: rgb(30 64 175 / var(--tw-text-opacity)); @@ -3405,6 +3444,11 @@ select { color: rgb(79 70 229 / var(--tw-text-opacity)); } +.text-red-100 { + --tw-text-opacity: 1; + color: rgb(254 226 226 / var(--tw-text-opacity)); +} + .text-red-400 { --tw-text-opacity: 1; color: rgb(248 113 113 / var(--tw-text-opacity)); @@ -3507,6 +3551,11 @@ select { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.outline-none { + outline: 2px solid transparent; + outline-offset: 2px; +} + .ring-2 { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); @@ -3522,6 +3571,19 @@ select { --tw-ring-color: rgb(79 70 229 / 0.2); } +.ring-offset-2 { + --tw-ring-offset-width: 2px; +} + +.ring-offset-white { + --tw-ring-offset-color: #fff; +} + +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } @@ -3796,6 +3858,11 @@ select { border-color: rgb(59 130 246 / var(--tw-border-opacity)); } +.focus\:bg-gray-100:focus { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + .focus\:underline:focus { text-decoration-line: underline; } @@ -3824,6 +3891,10 @@ select { --tw-ring-offset-width: 2px; } +.active\:transform:active { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .disabled\:opacity-50:disabled { opacity: 0.5; } @@ -3930,6 +4001,10 @@ select { background-color: rgb(185 28 28 / var(--tw-bg-opacity)); } +.dark\:bg-red-900\/40:is(.dark *) { + background-color: rgb(127 29 29 / 0.4); +} + .dark\:bg-yellow-200:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(254 240 138 / var(--tw-bg-opacity)); @@ -3968,6 +4043,11 @@ select { --tw-gradient-to: #3b0764 var(--tw-gradient-to-position); } +.dark\:text-blue-100:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(219 234 254 / var(--tw-text-opacity)); +} + .dark\:text-blue-200:is(.dark *) { --tw-text-opacity: 1; color: rgb(191 219 254 / var(--tw-text-opacity)); @@ -4190,6 +4270,11 @@ select { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.dark\:focus\:bg-gray-700:focus:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); +} + @media (min-width: 640px) { .sm\:col-span-3 { grid-column: span 3 / span 3; @@ -4297,10 +4382,26 @@ select { grid-column: span 2 / span 2; } + .md\:col-span-3 { + grid-column: span 3 / span 3; + } + .md\:mb-8 { margin-bottom: 2rem; } + .md\:block { + display: block; + } + + .md\:grid { + display: grid; + } + + .md\:hidden { + display: none; + } + .md\:h-\[140px\] { height: 140px; } diff --git a/staticfiles/django-htmx.js b/staticfiles/django-htmx.js new file mode 100644 index 00000000..aaf8eecc --- /dev/null +++ b/staticfiles/django-htmx.js @@ -0,0 +1,22 @@ +{ + const data = document.currentScript.dataset; + const isDebug = data.debug === "True"; + + if (isDebug) { + document.addEventListener("htmx:beforeOnLoad", function (event) { + const xhr = event.detail.xhr; + if (xhr.status == 500 || xhr.status == 404) { + // Tell htmx to stop processing this response + event.stopPropagation(); + + document.children[0].innerHTML = xhr.response; + + // Run Django’s inline script + // (1, eval) wtf - see https://stackoverflow.com/questions/9107240/1-evalthis-vs-evalthis-in-javascript + (1, eval)(document.scripts[0].innerText); + // Need to directly call Django’s onload function since browser won’t + window.onload(); + } + }); + } +} diff --git a/staticfiles/js/alerts.js b/staticfiles/js/alerts.js new file mode 100644 index 00000000..88e0d4cf --- /dev/null +++ b/staticfiles/js/alerts.js @@ -0,0 +1,21 @@ +document.addEventListener('DOMContentLoaded', () => { + const alerts = document.querySelectorAll('.alert'); + + alerts.forEach(alert => { + // Auto-hide alerts after 5 seconds + setTimeout(() => { + alert.classList.add('fade-out'); + setTimeout(() => { + alert.remove(); + }, 300); // Match CSS transition duration + }, 5000); + + // Add click-to-dismiss functionality + alert.addEventListener('click', () => { + alert.classList.add('fade-out'); + setTimeout(() => { + alert.remove(); + }, 300); + }); + }); +}); \ No newline at end of file diff --git a/staticfiles/js/main.js b/staticfiles/js/main.js new file mode 100644 index 00000000..4894e445 --- /dev/null +++ b/staticfiles/js/main.js @@ -0,0 +1,40 @@ +// Theme Toggle +document.addEventListener('DOMContentLoaded', () => { + const themeToggle = document.getElementById('theme-toggle'); + const themeIcon = themeToggle.nextElementSibling.querySelector('i'); + + // Set initial icon + updateThemeIcon(); + + themeToggle.addEventListener('change', () => { + if (document.documentElement.classList.contains('dark')) { + document.documentElement.classList.remove('dark'); + localStorage.setItem('theme', 'light'); + } else { + document.documentElement.classList.add('dark'); + localStorage.setItem('theme', 'dark'); + } + updateThemeIcon(); + }); + + function updateThemeIcon() { + const isDark = document.documentElement.classList.contains('dark'); + themeIcon.classList.remove('fa-sun', 'fa-moon'); + themeIcon.classList.add(isDark ? 'fa-sun' : 'fa-moon'); + } + + // Mobile Menu Toggle + const mobileMenuBtn = document.getElementById('mobileMenuBtn'); + const mobileMenu = document.getElementById('mobileMenu'); + const menuIcon = mobileMenuBtn.querySelector('i'); + + mobileMenu.style.display = 'none'; + let isMenuOpen = false; + + mobileMenuBtn.addEventListener('click', () => { + isMenuOpen = !isMenuOpen; + mobileMenu.style.display = isMenuOpen ? 'block' : 'none'; + menuIcon.classList.remove('fa-bars', 'fa-times'); + menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars'); + }); +}); \ No newline at end of file diff --git a/staticfiles/parks/css/search.css b/staticfiles/parks/css/search.css new file mode 100644 index 00000000..f887f5ae --- /dev/null +++ b/staticfiles/parks/css/search.css @@ -0,0 +1,134 @@ +/* Loading states */ +.htmx-request .htmx-indicator { + opacity: 1; +} +.htmx-request.htmx-indicator { + opacity: 1; +} +.htmx-indicator { + opacity: 0; + transition: opacity 200ms ease-in-out; +} + +/* Results container transitions */ +#park-results { + transition: opacity 200ms ease-in-out; +} +.htmx-request #park-results { + opacity: 0.7; +} +.htmx-settling #park-results { + opacity: 1; +} + +/* Grid/List transitions */ +.park-card { + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + background-color: white; + border-radius: 0.5rem; + border: 1px solid #e5e7eb; +} + +/* Grid view styles */ +.park-card[data-view-mode="grid"] { + display: flex; + flex-direction: column; +} +.park-card[data-view-mode="grid"]:hover { + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* List view styles */ +.park-card[data-view-mode="list"] { + display: flex; + gap: 1rem; + padding: 1rem; +} +.park-card[data-view-mode="list"]:hover { + background-color: #f9fafb; +} + +/* Image containers */ +.park-card .image-container { + position: relative; + overflow: hidden; +} +.park-card[data-view-mode="grid"] .image-container { + aspect-ratio: 16 / 9; + width: 100%; +} +.park-card[data-view-mode="list"] .image-container { + width: 6rem; + height: 6rem; + flex-shrink: 0; +} + +/* Content */ +.park-card .content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; /* Enables text truncation in flex child */ +} + +/* Status badges */ +.park-card .status-badge { + transition: all 150ms ease-in-out; +} +.park-card:hover .status-badge { + transform: scale(1.05); +} + +/* Images */ +.park-card img { + transition: transform 200ms ease-in-out; + object-fit: cover; + width: 100%; + height: 100%; +} +.park-card:hover img { + transform: scale(1.05); +} + +/* Placeholders for missing images */ +.park-card .placeholder { + background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%); + background-size: 200% 100%; + animation: shimmer 1.5s linear infinite; +} + +@keyframes shimmer { + to { + background-position: 200% center; + } +} + +/* Dark mode */ +@media (prefers-color-scheme: dark) { + .park-card { + background-color: #1f2937; + border-color: #374151; + } + + .park-card[data-view-mode="list"]:hover { + background-color: #374151; + } + + .park-card .text-gray-900 { + color: #f3f4f6; + } + + .park-card .text-gray-500 { + color: #9ca3af; + } + + .park-card .placeholder { + background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%); + } + + .park-card[data-view-mode="list"]:hover { + background-color: #374151; + } +} \ No newline at end of file diff --git a/staticfiles/parks/js/search.js b/staticfiles/parks/js/search.js new file mode 100644 index 00000000..66895b08 --- /dev/null +++ b/staticfiles/parks/js/search.js @@ -0,0 +1,69 @@ +// Handle view mode persistence across HTMX requests +document.addEventListener('htmx:configRequest', function(evt) { + // Preserve view mode + const parkResults = document.getElementById('park-results'); + if (parkResults) { + const viewMode = parkResults.getAttribute('data-view-mode'); + if (viewMode) { + evt.detail.parameters['view_mode'] = viewMode; + } + } + + // Preserve search terms + const searchInput = document.getElementById('search'); + if (searchInput && searchInput.value) { + evt.detail.parameters['search'] = searchInput.value; + } +}); + +// Handle loading states +document.addEventListener('htmx:beforeRequest', function(evt) { + const target = evt.detail.target; + if (target) { + target.classList.add('htmx-requesting'); + } +}); + +document.addEventListener('htmx:afterRequest', function(evt) { + const target = evt.detail.target; + if (target) { + target.classList.remove('htmx-requesting'); + } +}); + +// Handle history navigation +document.addEventListener('htmx:historyRestore', function(evt) { + const parkResults = document.getElementById('park-results'); + if (parkResults && evt.detail.path) { + const url = new URL(evt.detail.path, window.location.origin); + const viewMode = url.searchParams.get('view_mode'); + if (viewMode) { + parkResults.setAttribute('data-view-mode', viewMode); + } + } +}); + +// Initialize lazy loading for images +function initializeLazyLoading(container) { + if (!('IntersectionObserver' in window)) return; + + const imageObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const img = entry.target; + img.src = img.dataset.src; + img.removeAttribute('data-src'); + imageObserver.unobserve(img); + } + }); + }); + + container.querySelectorAll('img[data-src]').forEach(img => { + imageObserver.observe(img); + }); +} + +// Initialize lazy loading after HTMX content swaps +document.addEventListener('htmx:afterSwap', function(evt) { + initializeLazyLoading(evt.detail.target); +}); \ No newline at end of file diff --git a/templates/base/base.html b/templates/base/base.html index a377037d..1d3ee6af 100644 --- a/templates/base/base.html +++ b/templates/base/base.html @@ -31,7 +31,7 @@ - + diff --git a/thrillwiki/urls.py b/thrillwiki/urls.py index e8bbcd56..8d1f5f9b 100644 --- a/thrillwiki/urls.py +++ b/thrillwiki/urls.py @@ -7,6 +7,7 @@ from accounts import views as accounts_views from django.views.generic import TemplateView from .views import HomeView, SearchView from . import views +from autocomplete.urls import urlpatterns as autocomplete_patterns import os urlpatterns = [ @@ -58,6 +59,8 @@ urlpatterns = [ views***REMOVED***ironment_and_settings_view, name="environment_and_settings", ), + # Autocomplete URLs + path("ac/", include((autocomplete_patterns, "autocomplete"), namespace="autocomplete")), ] # Serve static files in development