Add autocomplete functionality for parks: implement URLs, views, and templates for real-time suggestions

This commit is contained in:
pacnpal
2025-02-23 13:07:27 -05:00
parent 8e9b6b6a15
commit 1876af46d9
26 changed files with 862 additions and 191 deletions

49
autocomplete/__init__.py Normal file
View File

@@ -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)

25
autocomplete/apps.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1,20 @@
{% if results %}
<ul class="py-1 overflow-auto max-h-60" role="listbox">
{% for result in results %}
<li class="px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
role="option"
@click="selectedId = '{{ result.key }}'; query = '{{ result.label }}'; $refs.filterForm.requestSubmit()">
<div class="flex flex-col">
<span class="font-medium">{{ result.label }}</span>
{% if result.extra %}
<span class="text-sm text-gray-500 dark:text-gray-400">{{ result.extra }}</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="px-4 py-2 text-gray-500 dark:text-gray-400">
No results found
</div>
{% endif %}

View File

@@ -0,0 +1,38 @@
{% load static %}
<div class="relative" x-data="{ query: '', selectedId: null }">
<input type="text"
name="{{ widget.name }}_search"
placeholder="{{ widget.attrs.placeholder|default:'Search...' }}"
class="{{ widget.attrs.class }}"
x-model="query"
@keydown.escape="query = ''"
hx-get="{% url 'autocomplete:items' ac_name %}"
hx-trigger="input changed delay:300ms"
hx-target="#{{ widget.name }}-suggestions"
hx-indicator="#{{ widget.name }}-indicator">
<input type="hidden"
name="{{ widget.name }}"
x-model="selectedId">
<!-- Loading indicator -->
<div id="{{ widget.name }}-indicator"
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results">
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span class="sr-only">Searching...</span>
</div>
<!-- Suggestions dropdown -->
<div id="{{ widget.name }}-suggestions"
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox"
style="display: none;"
x-show="query.length > 0">
</div>
</div>

9
autocomplete/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = 'autocomplete'
urlpatterns = [
path('<str:ac_name>/items/', views.items, name='items'),
path('<str:ac_name>/toggle/', views.toggle, name='toggle'),
]

52
autocomplete/views.py Normal file
View File

@@ -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)

View File

@@ -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
<form id="filter-form"
x-ref="filterForm"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-trigger="submit"
class="mt-4">
<div class="mb-6">
{{ search_form }} <!-- AutocompleteWidget -->
</div>
{% include "search/components/filter_form.html" with filter=filter %}
</form>
```
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

15
parks/autocomplete.py Normal file
View File

@@ -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"

View File

@@ -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."""

View File

@@ -47,69 +47,24 @@
{% block filter_section %}
<div class="mb-6">
<div class="max-w-3xl mx-auto relative mb-8">
<div class="w-full relative"
x-data="{ query: '', selectedId: null }"
@search-selected.window="
query = $event.detail;
selectedId = $event.target.value;
$refs.filterForm.querySelector('input[name=search]').value = query;
$refs.filterForm.submit();
query = '';
">
<form hx-get="{% url 'parks:suggest_parks' %}"
hx-target="#search-results"
hx-trigger="input changed delay:300ms"
hx-indicator="#search-indicator"
x-ref="searchForm">
<div class="relative">
<input type="search"
name="search"
placeholder="Search parks..."
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
aria-label="Search parks"
aria-controls="search-results"
:aria-expanded="query !== ''"
x-model="query"
@keydown.escape="query = ''">
<!-- Loading indicator -->
<div id="search-indicator"
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results">
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span class="sr-only">Searching...</span>
</div>
</div>
</form>
<div id="search-results"
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox">
<!-- Search suggestions will be loaded here -->
</div>
</div>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
<form id="filter-form"
x-ref="filterForm"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-trigger="change, submit"
hx-trigger="submit"
class="mt-4">
<input type="hidden" name="search" value="{{ request.GET.search }}">
<div class="mb-6">
{{ search_form }}
</div>
{% include "search/components/filter_form.html" with filter=filter %}
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block results_list %}

View File

@@ -1,37 +1,21 @@
{% load filter_utils %}
{% if suggestions %}
<div id="search-suggestions-results"
class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
x-show="open"
x-cloak
@keydown.escape.window="open = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95">
{% for park in suggestions %}
{% with location=park.location.first %}
<button type="button"
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between gap-2 transition duration-150"
:class="{ 'bg-blue-50': focusedIndex === {{ forloop.counter0 }} }"
@mousedown.prevent="query = '{{ park.name }}'; $refs.search.value = '{{ park.name }}'"
@mousedown.prevent="query = '{{ park.name }}'; $dispatch('search-selected', '{{ park.name }}'); open = false;"
role="option"
:aria-selected="focusedIndex === {{ forloop.counter0 }}"
tabindex="-1"
x-effect="if(focusedIndex === {{ forloop.counter0 }}) $el.scrollIntoView({block: 'nearest'})"
aria-label="{{ park.name }}{% if location.city %} in {{ location.city }}{% endif %}{% if location.state %}, {{ location.state }}{% endif %}">
<div class="flex items-center gap-2">
<span class="font-medium" x-text="focusedIndex === {{ forloop.counter0 }} ? '▶ {{ park.name }}' : '{{ park.name }}'"></span>
<span class="text-gray-500">
{% if location.city %}{{ location.city }}, {% endif %}
{% if location.state %}{{ location.state }}{% endif %}
{% if results %}
<div class="py-1">
{% for result in results %}
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="$dispatch('search-selected', '{{ result.name }}')"
value="{{ result.id }}"
role="option">
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ result.name }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ result.status }}{% if result.location %} • {{ result.location }}{% endif %}
</span>
</div>
</button>
{% endwith %}
{% endfor %}
</div>
{% else %}
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
{% if query %}No parks found matching "{{ query }}"{% else %}Start typing to search parks{% endif %}
</div>
{% endif %}

View File

@@ -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

View File

@@ -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,11 +21,7 @@ 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:
# Get filtered queryset
queryset = filter_instance.qs
# Handle view mode
@@ -32,23 +29,3 @@ class ParkSearchView(TemplateView):
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})

View File

@@ -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%);
.alert.fade-out {
opacity: 0;
}
}

21
static/js/alerts.js Normal file
View File

@@ -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);
});
});
});

View File

@@ -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 = '<option value="">Select a city</option>';
}
});
// 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 = '<option value="">Select a region</option>';
data.regions.forEach(region => {
const option = new Option(region, region);
regionInput.add(option);
});
});
}
function updateCities(country, region) {
fetch(`/location/cities/?country=${encodeURIComponent(country)}&region=${encodeURIComponent(region)}`)
.then(response => response.json())
.then(data => {
cityInput.innerHTML = '<option value="">Select a city</option>';
data.cities.forEach(city => {
const option = new Option(city, city);
cityInput.add(option);
});
});
}
});

40
static/js/main.js Normal file
View File

@@ -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');
});
});

View File

@@ -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%);
.alert.fade-out {
opacity: 0;
}
}

View File

@@ -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;
}

View File

@@ -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 Djangos 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 Djangos onload function since browser wont
window.onload();
}
});
}
}

21
staticfiles/js/alerts.js Normal file
View File

@@ -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);
});
});
});

40
staticfiles/js/main.js Normal file
View File

@@ -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');
});
});

View File

@@ -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;
}
}

View File

@@ -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);
});

View File

@@ -31,7 +31,7 @@
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<!-- Alpine.js -->
<script defer src="{% static 'js/alpine.min.js' %}"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Location Autocomplete -->
<script src="{% static 'js/location-autocomplete.js' %}"></script>

View File

@@ -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