mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 01:51:09 -05:00
Add autocomplete functionality for parks: implement URLs, views, and templates for real-time suggestions
This commit is contained in:
49
autocomplete/__init__.py
Normal file
49
autocomplete/__init__.py
Normal 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
25
autocomplete/apps.py
Normal 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)
|
||||
20
autocomplete/templates/autocomplete/suggestions.html
Normal file
20
autocomplete/templates/autocomplete/suggestions.html
Normal 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 %}
|
||||
38
autocomplete/templates/autocomplete/widget.html
Normal file
38
autocomplete/templates/autocomplete/widget.html
Normal 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
9
autocomplete/urls.py
Normal 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
52
autocomplete/views.py
Normal 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)
|
||||
61
memory-bank/decisions/search_duplication_fix.md
Normal file
61
memory-bank/decisions/search_duplication_fix.md
Normal 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
15
parks/autocomplete.py
Normal 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"
|
||||
@@ -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."""
|
||||
|
||||
@@ -47,68 +47,23 @@
|
||||
{% 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 class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<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 }}
|
||||
</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 -->
|
||||
{% include "search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</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"
|
||||
class="mt-4">
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% include "search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
21
static/js/alerts.js
Normal file
21
static/js/alerts.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
static/js/location-autocomplete.js
Normal file
50
static/js/location-autocomplete.js
Normal 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)}®ion=${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
40
static/js/main.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
22
staticfiles/django-htmx.js
Normal file
22
staticfiles/django-htmx.js
Normal 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 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
21
staticfiles/js/alerts.js
Normal file
21
staticfiles/js/alerts.js
Normal 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
40
staticfiles/js/main.js
Normal 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');
|
||||
});
|
||||
});
|
||||
134
staticfiles/parks/css/search.css
Normal file
134
staticfiles/parks/css/search.css
Normal 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;
|
||||
}
|
||||
}
|
||||
69
staticfiles/parks/js/search.js
Normal file
69
staticfiles/parks/js/search.js
Normal 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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user