Compare commits

..

3 Commits

44 changed files with 849 additions and 1153 deletions

View File

@@ -28,3 +28,16 @@ This applies to all management commands including but not limited to:
- Starting shell: `uv run manage.py shell` - Starting shell: `uv run manage.py shell`
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly. NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
## Static Files Management
IMPORTANT: All static files must be placed in the `static/` directory, not `staticfiles/`. The `staticfiles/` directory is reserved for Django's collectstatic command output and should not be used directly.
This consolidation:
1. Follows Django best practices of separating source static files from collected files
2. Prevents confusion between development and production static file locations
3. Makes it clear which static files are part of the source code (static/) versus compiled/collected (staticfiles/)
When adding new static files:
- Add them to `static/` directory
- Use Django's `static` template tag to reference them
- Run `uv run manage.py collectstatic` when deploying

View File

@@ -4,8 +4,8 @@ from django.contrib.auth.models import Group
from django.db import transaction from django.db import transaction
from django.core.files import File from django.core.files import File
from django.core.files.temp import NamedTemporaryFile from django.core.files.temp import NamedTemporaryFile
import requests
from .models import User, UserProfile, EmailVerification from .models import User, UserProfile, EmailVerification
from security import safe_requests
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs): def create_user_profile(sender, instance, created, **kwargs):
@@ -31,7 +31,7 @@ def create_user_profile(sender, instance, created, **kwargs):
if avatar_url: if avatar_url:
try: try:
response = safe_requests.get(avatar_url, timeout=60) response = requests.get(avatar_url, timeout=60)
if response.status_code == 200: if response.status_code == 200:
img_temp = NamedTemporaryFile(delete=True) img_temp = NamedTemporaryFile(delete=True)
img_temp.write(response.content) img_temp.write(response.content)

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

@@ -12,7 +12,6 @@ from django.db.models import Q
from location.forms import LocationForm from location.forms import LocationForm
from .models import Location from .models import Location
from security import safe_requests
class LocationSearchView(View): class LocationSearchView(View):
""" """
@@ -52,7 +51,7 @@ class LocationSearchView(View):
elif filter_type == 'city': elif filter_type == 'city':
params['featuretype'] = 'city' params['featuretype'] = 'city'
response = safe_requests.get( response = requests.get(
'https://nominatim.openstreetmap.org/search', 'https://nominatim.openstreetmap.org/search',
params=params, params=params,
headers={'User-Agent': 'ThrillWiki/1.0'}, headers={'User-Agent': 'ThrillWiki/1.0'},
@@ -165,7 +164,7 @@ def reverse_geocode(request):
return JsonResponse(cached_result) return JsonResponse(cached_result)
try: try:
response = safe_requests.get( response = requests.get(
'https://nominatim.openstreetmap.org/reverse', 'https://nominatim.openstreetmap.org/reverse',
params={ params={
'lat': lat, 'lat': lat,

View File

@@ -1,4 +1,5 @@
import os import os
import requests
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.core.files import File from django.core.files import File
from django.core.files.temp import NamedTemporaryFile from django.core.files.temp import NamedTemporaryFile
@@ -8,7 +9,6 @@ from rides.models import Ride
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
import json import json
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from security import safe_requests
class Command(BaseCommand): class Command(BaseCommand):
help = 'Download photos from seed data URLs' help = 'Download photos from seed data URLs'
@@ -33,7 +33,7 @@ class Command(BaseCommand):
try: try:
# Download image # Download image
self.stdout.write(f'Downloading from URL: {photo_url}') self.stdout.write(f'Downloading from URL: {photo_url}')
response = safe_requests.get(photo_url, timeout=60) response = requests.get(photo_url, timeout=60)
if response.status_code == 200: if response.status_code == 200:
# Delete any existing photos for this park # Delete any existing photos for this park
Photo.objects.filter( Photo.objects.filter(
@@ -74,7 +74,7 @@ class Command(BaseCommand):
try: try:
# Download image # Download image
self.stdout.write(f'Downloading from URL: {photo_url}') self.stdout.write(f'Downloading from URL: {photo_url}')
response = safe_requests.get(photo_url, timeout=60) response = requests.get(photo_url, timeout=60)
if response.status_code == 200: if response.status_code == 200:
# Delete any existing photos for this ride # Delete any existing photos for this ride
Photo.objects.filter( Photo.objects.filter(

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

View File

@@ -21,6 +21,23 @@
- Implement component-based structure - Implement component-based structure
- Follow progressive enhancement - Follow progressive enhancement
### Static Files Organization
1. Directory Structure
- `static/` - Source static files (CSS, JS, images, etc.)
- `staticfiles/` - Collected files (generated by collectstatic)
2. File Management Rules
- Place all source static files in `static/` directory
- Never directly modify `staticfiles/` directory
- Use Django's `static` template tag for references
- Run collectstatic before deployment
3. Benefits of Separation
- Clear distinction between source and compiled files
- Prevents confusion in development vs production
- Follows Django best practices
- Simplifies deployment process
## Design Patterns ## Design Patterns
### Data Access ### Data Access

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 django import forms
from decimal import Decimal, InvalidOperation, ROUND_DOWN 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 .models import Park
from location.models import Location from location.models import Location
from .querysets import get_base_park_queryset from .querysets import get_base_park_queryset
class ParkAutocomplete(BaseAutocomplete): class ParkAutocomplete(ModelAutocomplete):
"""Autocomplete for searching parks. """Autocomplete for searching parks.
Features: Features:
@@ -19,6 +18,8 @@ class ParkAutocomplete(BaseAutocomplete):
""" """
model = Park model = Park
search_attrs = ['name'] # We'll match on park names 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): def get_search_results(self, search):
"""Return search results with related data.""" """Return search results with related data."""

View File

@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.files.temp import NamedTemporaryFile from django.core.files.temp import NamedTemporaryFile
from django.core.files import File from django.core.files import File
import requests
from parks.models import Park from parks.models import Park
from rides.models import Ride, RollerCoasterStats from rides.models import Ride, RollerCoasterStats
from companies.models import Company, Manufacturer from companies.models import Company, Manufacturer
@@ -14,7 +15,6 @@ from media.models import Photo
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from datetime import datetime, timedelta from datetime import datetime, timedelta
import secrets import secrets
from security import safe_requests
User = get_user_model() User = get_user_model()
@@ -189,7 +189,7 @@ class Command(BaseCommand):
def download_image(self, url): def download_image(self, url):
"""Download image from URL and return as Django File object""" """Download image from URL and return as Django File object"""
response = safe_requests.get(url, timeout=60) response = requests.get(url, timeout=60)
if response.status_code == 200: if response.status_code == 200:
img_temp = NamedTemporaryFile(delete=True) img_temp = NamedTemporaryFile(delete=True)
img_temp.write(response.content) img_temp.write(response.content)

View File

@@ -47,68 +47,23 @@
{% block filter_section %} {% block filter_section %}
<div class="mb-6"> <div class="mb-6">
<div class="max-w-3xl mx-auto relative mb-8"> <div class="max-w-3xl mx-auto relative mb-8">
<div class="w-full relative" <div class="bg-white shadow sm:rounded-lg">
x-data="{ query: '', selectedId: null }" <div class="px-4 py-5 sm:p-6">
@search-selected.window=" <form id="filter-form"
query = $event.detail; x-ref="filterForm"
selectedId = $event.target.value; hx-get="{% url 'parks:park_list' %}"
$refs.filterForm.querySelector('input[name=search]').value = query; hx-target="#park-results"
$refs.filterForm.submit(); hx-push-url="true"
query = ''; hx-trigger="submit"
"> class="mt-4">
<form hx-get="{% url 'parks:suggest_parks' %}" <div class="mb-6">
hx-target="#search-results" {{ search_form }}
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>
</div> {% include "search/components/filter_form.html" with filter=filter %}
</form> </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>
</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> </div>
{% endblock %} {% endblock %}

View File

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

View File

@@ -18,8 +18,6 @@ urlpatterns = [
# Areas and search endpoints for HTMX # Areas and search endpoints for HTMX
path("areas/", views.get_park_areas, name="get_park_areas"), 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"), path("search/", views.search_parks, name="search_parks"),
# Park detail and related views # Park detail and related views

View File

@@ -19,9 +19,9 @@ from django.urls import reverse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from decimal import InvalidOperation from decimal import InvalidOperation
from django.views.generic import DetailView, ListView, CreateView, UpdateView from django.views.generic import DetailView, ListView, CreateView, UpdateView
import requests
from decimal import Decimal, ROUND_DOWN from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional, cast, Literal from typing import Any, Optional, cast, Literal
from security import safe_requests
# Constants # Constants
PARK_DETAIL_URL = "parks:park_detail" PARK_DETAIL_URL = "parks:park_detail"
@@ -140,7 +140,7 @@ def location_search(request: HttpRequest) -> JsonResponse:
if not query: if not query:
return JsonResponse({"results": []}) return JsonResponse({"results": []})
response = safe_requests.get( response = requests.get(
"https://nominatim.openstreetmap.org/search", "https://nominatim.openstreetmap.org/search",
params={ params={
"q": query, "q": query,
@@ -186,7 +186,7 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
if lon < -180 or lon > 180: if lon < -180 or lon > 180:
return JsonResponse({"error": "Longitude must be between -180 and 180"}, status=400) return JsonResponse({"error": "Longitude must be between -180 and 180"}, status=400)
response = safe_requests.get( response = requests.get(
"https://nominatim.openstreetmap.org/reverse", "https://nominatim.openstreetmap.org/reverse",
params={ params={
"lat": str(lat), "lat": str(lat),

View File

@@ -1,5 +1,4 @@
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.urls import reverse from django.urls import reverse
@@ -13,6 +12,8 @@ class ParkSearchView(TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# Initialize search form
context['search_form'] = ParkSearchForm(self.request.GET) context['search_form'] = ParkSearchForm(self.request.GET)
# Initialize filter with current querystring # Initialize filter with current querystring
@@ -20,35 +21,11 @@ class ParkSearchView(TemplateView):
filter_instance = ParkFilter(self.request.GET, queryset=queryset) filter_instance = ParkFilter(self.request.GET, queryset=queryset)
context['filter'] = filter_instance context['filter'] = filter_instance
# Apply search if park ID selected via autocomplete # Get filtered queryset
park_id = self.request.GET.get('park') queryset = filter_instance.qs
if park_id:
queryset = filter_instance.qs.filter(id=park_id)
else:
queryset = filter_instance.qs
# Handle view mode # Handle view mode
context['view_mode'] = self.request.GET.get('view_mode', 'grid') context['view_mode'] = self.request.GET.get('view_mode', 'grid')
context['parks'] = queryset context['parks'] = queryset
return context 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

@@ -9,7 +9,6 @@ python = "^3.11"
Django = "^5.0" Django = "^5.0"
djangorestframework = "^3.14.0" djangorestframework = "^3.14.0"
django-cors-headers = "^4.3.1" django-cors-headers = "^4.3.1"
security = "==1.3.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
black = "^25.1.0" black = "^25.1.0"

View File

@@ -1,44 +1,37 @@
/* Alert Styles */
.alert { .alert {
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4; padding: 1rem;
animation: slideIn 0.5s ease-out forwards; 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 { .alert-success {
@apply text-white bg-green-500; background-color: #E8F5E9;
border: 1px solid #A5D6A7;
color: #2E7D32;
} }
.alert-error { .alert-error {
@apply text-white bg-red-500; background-color: #FFEBEE;
} border: 1px solid #FFCDD2;
color: #C62828;
.alert-info {
@apply text-white bg-blue-500;
} }
.alert-warning { .alert-warning {
@apply text-white bg-yellow-500; background-color: #FFF3E0;
border: 1px solid #FFCC80;
color: #EF6C00;
} }
/* Animation keyframes */ .alert-info {
@keyframes slideIn { background-color: #E3F2FD;
0% { border: 1px solid #90CAF9;
transform: translateX(100%); color: #1565C0;
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
} }
@keyframes slideOut { .alert.fade-out {
0% { opacity: 0;
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
} }

View File

@@ -1,18 +1,21 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', () => {
// Get all alert elements
const alerts = document.querySelectorAll('.alert'); const alerts = document.querySelectorAll('.alert');
// For each alert
alerts.forEach(alert => { alerts.forEach(alert => {
// After 5 seconds // Auto-hide alerts after 5 seconds
setTimeout(() => { setTimeout(() => {
// Add slideOut animation alert.classList.add('fade-out');
alert.style.animation = 'slideOut 0.5s ease-out forwards';
// Remove the alert after animation completes
setTimeout(() => { setTimeout(() => {
alert.remove(); alert.remove();
}, 500); }, 300); // Match CSS transition duration
}, 5000); }, 5000);
// Add click-to-dismiss functionality
alert.addEventListener('click', () => {
alert.classList.add('fade-out');
setTimeout(() => {
alert.remove();
}, 300);
});
}); });
}); });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,81 +1,50 @@
function locationAutocomplete(field, filterParks = false) { document.addEventListener('DOMContentLoaded', () => {
return { const countryInput = document.querySelector('[name="country"]');
query: '', const regionInput = document.querySelector('[name="region"]');
suggestions: [], const cityInput = document.querySelector('[name="city"]');
fetchSuggestions() {
let url;
const params = new URLSearchParams({
q: this.query,
filter_parks: filterParks
});
switch (field) { if (!countryInput || !regionInput || !cityInput) return;
case 'country':
url = '/parks/ajax/countries/';
break;
case 'region':
url = '/parks/ajax/regions/';
// Add country parameter if we're fetching regions
const countryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (countryInput && countryInput.value) {
params.append('country', countryInput.value);
}
break;
case 'city':
url = '/parks/ajax/cities/';
// Add country and region parameters if we're fetching cities
const regionInput = document.getElementById(filterParks ? 'region' : 'id_region_name');
const cityCountryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (regionInput && regionInput.value && cityCountryInput && cityCountryInput.value) {
params.append('country', cityCountryInput.value);
params.append('region', regionInput.value);
}
break;
}
if (url) { // Update regions when country changes
fetch(`${url}?${params}`) countryInput.addEventListener('change', () => {
.then(response => response.json()) const country = countryInput.value;
.then(data => { if (country) {
this.suggestions = data; updateRegions(country);
}); // Clear city when country changes
} cityInput.innerHTML = '<option value="">Select a city</option>';
},
selectSuggestion(suggestion) {
this.query = suggestion.name;
this.suggestions = [];
// If this is a form field (not filter), update hidden fields
if (!filterParks) {
const hiddenField = document.getElementById(`id_${field}`);
if (hiddenField) {
hiddenField.value = suggestion.id;
}
// Clear dependent fields when parent field changes
if (field === 'country') {
const regionInput = document.getElementById('id_region_name');
const cityInput = document.getElementById('id_city_name');
const regionHidden = document.getElementById('id_region');
const cityHidden = document.getElementById('id_city');
if (regionInput) regionInput.value = '';
if (cityInput) cityInput.value = '';
if (regionHidden) regionHidden.value = '';
if (cityHidden) cityHidden.value = '';
} else if (field === 'region') {
const cityInput = document.getElementById('id_city_name');
const cityHidden = document.getElementById('id_city');
if (cityInput) cityInput.value = '';
if (cityHidden) cityHidden.value = '';
}
}
// Trigger form submission for filters
if (filterParks) {
htmx.trigger('#park-filters', 'change');
}
} }
}; });
}
// 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);
});
});
}
});

View File

@@ -1,141 +1,40 @@
// Theme handling // Theme Toggle
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle'); const themeToggle = document.getElementById('theme-toggle');
const html = document.documentElement; const themeIcon = themeToggle.nextElementSibling.querySelector('i');
// Initialize toggle state based on current theme // Set initial icon
if (themeToggle) { updateThemeIcon();
themeToggle.checked = html.classList.contains('dark');
// Handle toggle changes themeToggle.addEventListener('change', () => {
themeToggle.addEventListener('change', function() { if (document.documentElement.classList.contains('dark')) {
const isDark = this.checked; document.documentElement.classList.remove('dark');
html.classList.toggle('dark', isDark); localStorage.setItem('theme', 'light');
localStorage.setItem('theme', isDark ? 'dark' : 'light'); } else {
}); document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
const isDark = e.matches;
html.classList.toggle('dark', isDark);
themeToggle.checked = isDark;
}
});
}
});
// Handle search form submission
document.addEventListener('submit', (e) => {
if (e.target.matches('form[action*="search"]')) {
const searchInput = e.target.querySelector('input[name="q"]');
if (!searchInput.value.trim()) {
e.preventDefault();
} }
} updateThemeIcon();
}); });
// Mobile menu toggle with transitions function updateThemeIcon() {
document.addEventListener('DOMContentLoaded', () => { 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 mobileMenuBtn = document.getElementById('mobileMenuBtn');
const mobileMenu = document.getElementById('mobileMenu'); const mobileMenu = document.getElementById('mobileMenu');
const menuIcon = mobileMenuBtn.querySelector('i');
if (mobileMenuBtn && mobileMenu) { mobileMenu.style.display = 'none';
let isMenuOpen = false; let isMenuOpen = false;
const toggleMenu = () => { mobileMenuBtn.addEventListener('click', () => {
isMenuOpen = !isMenuOpen; isMenuOpen = !isMenuOpen;
mobileMenu.classList.toggle('show', isMenuOpen); mobileMenu.style.display = isMenuOpen ? 'block' : 'none';
mobileMenuBtn.setAttribute('aria-expanded', isMenuOpen.toString()); menuIcon.classList.remove('fa-bars', 'fa-times');
menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
// Update icon
const icon = mobileMenuBtn.querySelector('i');
icon.classList.remove(isMenuOpen ? 'fa-bars' : 'fa-times');
icon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
};
mobileMenuBtn.addEventListener('click', toggleMenu);
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (isMenuOpen && !mobileMenu.contains(e.target) && !mobileMenuBtn.contains(e.target)) {
toggleMenu();
}
});
// Close menu when pressing escape
document.addEventListener('keydown', (e) => {
if (isMenuOpen && e.key === 'Escape') {
toggleMenu();
}
});
// Handle viewport changes
const mediaQuery = window.matchMedia('(min-width: 1024px)');
mediaQuery.addEventListener('change', (e) => {
if (e.matches && isMenuOpen) {
toggleMenu();
}
});
}
});
// User dropdown toggle
const userMenuBtn = document.getElementById('userMenuBtn');
const userDropdown = document.getElementById('userDropdown');
if (userMenuBtn && userDropdown) {
userMenuBtn.addEventListener('click', (e) => {
e.stopPropagation();
userDropdown.classList.toggle('active');
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!userMenuBtn.contains(e.target) && !userDropdown.contains(e.target)) {
userDropdown.classList.remove('active');
}
});
// Close dropdown when pressing escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
userDropdown.classList.remove('active');
}
});
}
// Handle flash messages
document.addEventListener('DOMContentLoaded', () => {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(alert => {
setTimeout(() => {
alert.style.opacity = '0';
setTimeout(() => alert.remove(), 300);
}, 5000);
});
});
// Initialize tooltips
document.addEventListener('DOMContentLoaded', () => {
const tooltips = document.querySelectorAll('[data-tooltip]');
tooltips.forEach(tooltip => {
tooltip.addEventListener('mouseenter', (e) => {
const text = e.target.getAttribute('data-tooltip');
const tooltipEl = document.createElement('div');
tooltipEl.className = 'absolute z-50 px-2 py-1 text-sm text-white bg-gray-900 rounded tooltip';
tooltipEl.textContent = text;
document.body.appendChild(tooltipEl);
const rect = e.target.getBoundingClientRect();
tooltipEl.style.top = rect.bottom + 5 + 'px';
tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px';
});
tooltip.addEventListener('mouseleave', () => {
const tooltips = document.querySelectorAll('.tooltip');
tooltips.forEach(t => t.remove());
});
}); });
}); });

View File

@@ -1,29 +0,0 @@
// Only declare parkMap if it doesn't exist
window.parkMap = window.parkMap || null;
function initParkMap(latitude, longitude, name) {
const mapContainer = document.getElementById('park-map');
// Only initialize if container exists and map hasn't been initialized
if (mapContainer && !window.parkMap) {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
window.parkMap = L.map('park-map').setView([latitude, longitude], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(window.parkMap);
L.marker([latitude, longitude])
.addTo(window.parkMap)
.bindPopup(name);
// Update map size when window is resized
window.addEventListener('resize', function() {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
window.parkMap.invalidateSize();
});
}
}

View File

@@ -1,91 +0,0 @@
document.addEventListener('alpine:init', () => {
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
photos,
fullscreenPhoto: null,
uploading: false,
uploadProgress: 0,
error: null,
showSuccess: false,
showFullscreen(photo) {
this.fullscreenPhoto = photo;
},
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) {
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
this.showSuccess = false;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
break;
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
if (!this.error) {
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
},
async sharePhoto(photo) {
if (navigator.share) {
try {
await navigator.share({
title: photo.caption || 'Shared photo',
url: photo.url
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
} else {
// Fallback: copy URL to clipboard
navigator.clipboard.writeText(photo.url)
.then(() => alert('Photo URL copied to clipboard!'))
.catch(err => console.error('Error copying to clipboard:', err));
}
}
}));
});

View File

@@ -1,42 +0,0 @@
function parkSearch() {
return {
query: '',
results: [],
loading: false,
selectedId: null,
async search() {
if (!this.query.trim()) {
this.results = [];
return;
}
this.loading = true;
try {
const response = await fetch(`/parks/suggest_parks/?search=${encodeURIComponent(this.query)}`);
const data = await response.json();
this.results = data.results;
} catch (error) {
console.error('Search failed:', error);
this.results = [];
} finally {
this.loading = false;
}
},
clear() {
this.query = '';
this.results = [];
this.selectedId = null;
},
selectPark(park) {
this.query = park.name;
this.selectedId = park.id;
this.results = [];
// Trigger filter update
document.getElementById('park-filters').dispatchEvent(new Event('change'));
}
};
}

View File

@@ -1,44 +1,37 @@
/* Alert Styles */
.alert { .alert {
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4; padding: 1rem;
animation: slideIn 0.5s ease-out forwards; 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 { .alert-success {
@apply text-white bg-green-500; background-color: #E8F5E9;
border: 1px solid #A5D6A7;
color: #2E7D32;
} }
.alert-error { .alert-error {
@apply text-white bg-red-500; background-color: #FFEBEE;
} border: 1px solid #FFCDD2;
color: #C62828;
.alert-info {
@apply text-white bg-blue-500;
} }
.alert-warning { .alert-warning {
@apply text-white bg-yellow-500; background-color: #FFF3E0;
border: 1px solid #FFCC80;
color: #EF6C00;
} }
/* Animation keyframes */ .alert-info {
@keyframes slideIn { background-color: #E3F2FD;
0% { border: 1px solid #90CAF9;
transform: translateX(100%); color: #1565C0;
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
} }
@keyframes slideOut { .alert.fade-out {
0% { opacity: 0;
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
} }

View File

@@ -2181,6 +2181,18 @@ select {
justify-content: center; 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 { .visible {
visibility: visible; visibility: visible;
} }
@@ -2457,6 +2469,10 @@ select {
display: none; display: none;
} }
.h-10 {
height: 2.5rem;
}
.h-16 { .h-16 {
height: 4rem; height: 4rem;
} }
@@ -2485,6 +2501,10 @@ select {
height: 1.25rem; height: 1.25rem;
} }
.h-6 {
height: 1.5rem;
}
.h-8 { .h-8 {
height: 2rem; height: 2rem;
} }
@@ -2533,6 +2553,10 @@ select {
width: 1.25rem; width: 1.25rem;
} }
.w-6 {
width: 1.5rem;
}
.w-64 { .w-64 {
width: 16rem; 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)); 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 { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@@ -3000,10 +3034,6 @@ select {
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); 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 { .bg-green-100 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(220 252 231 / var(--tw-bg-opacity)); background-color: rgb(220 252 231 / var(--tw-bg-opacity));
@@ -3244,6 +3274,10 @@ select {
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.pt-2 {
padding-top: 0.5rem;
}
.text-left { .text-left {
text-align: left; text-align: left;
} }
@@ -3335,6 +3369,11 @@ select {
color: rgb(37 99 235 / var(--tw-text-opacity)); 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 { .text-blue-800 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity)); color: rgb(30 64 175 / var(--tw-text-opacity));
@@ -3405,6 +3444,11 @@ select {
color: rgb(79 70 229 / var(--tw-text-opacity)); 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 { .text-red-400 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity)); 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); 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 { .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-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); --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); --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 {
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: 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)); 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 { .focus\:underline:focus {
text-decoration-line: underline; text-decoration-line: underline;
} }
@@ -3824,6 +3891,10 @@ select {
--tw-ring-offset-width: 2px; --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 { .disabled\:opacity-50:disabled {
opacity: 0.5; opacity: 0.5;
} }
@@ -3930,6 +4001,10 @@ select {
background-color: rgb(185 28 28 / var(--tw-bg-opacity)); 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 *) { .dark\:bg-yellow-200:is(.dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(254 240 138 / var(--tw-bg-opacity)); background-color: rgb(254 240 138 / var(--tw-bg-opacity));
@@ -3968,6 +4043,11 @@ select {
--tw-gradient-to: #3b0764 var(--tw-gradient-to-position); --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 *) { .dark\:text-blue-200:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity)); color: rgb(191 219 254 / var(--tw-text-opacity));
@@ -4190,6 +4270,11 @@ select {
color: rgb(255 255 255 / var(--tw-text-opacity)); 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) { @media (min-width: 640px) {
.sm\:col-span-3 { .sm\:col-span-3 {
grid-column: span 3 / span 3; grid-column: span 3 / span 3;
@@ -4297,10 +4382,26 @@ select {
grid-column: span 2 / span 2; grid-column: span 2 / span 2;
} }
.md\:col-span-3 {
grid-column: span 3 / span 3;
}
.md\:mb-8 { .md\:mb-8 {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.md\:block {
display: block;
}
.md\:grid {
display: grid;
}
.md\:hidden {
display: none;
}
.md\:h-\[140px\] { .md\:h-\[140px\] {
height: 140px; height: 140px;
} }

View File

@@ -1,18 +1,21 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', () => {
// Get all alert elements
const alerts = document.querySelectorAll('.alert'); const alerts = document.querySelectorAll('.alert');
// For each alert
alerts.forEach(alert => { alerts.forEach(alert => {
// After 5 seconds // Auto-hide alerts after 5 seconds
setTimeout(() => { setTimeout(() => {
// Add slideOut animation alert.classList.add('fade-out');
alert.style.animation = 'slideOut 0.5s ease-out forwards';
// Remove the alert after animation completes
setTimeout(() => { setTimeout(() => {
alert.remove(); alert.remove();
}, 500); }, 300); // Match CSS transition duration
}, 5000); }, 5000);
// Add click-to-dismiss functionality
alert.addEventListener('click', () => {
alert.classList.add('fade-out');
setTimeout(() => {
alert.remove();
}, 300);
});
}); });
}); });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,262 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Handle edit button clicks
document.querySelectorAll('[data-edit-button]').forEach(button => {
button.addEventListener('click', function() {
const contentId = this.dataset.contentId;
const contentType = this.dataset.contentType;
const editableFields = document.querySelectorAll(`[data-editable][data-content-id="${contentId}"]`);
// Toggle edit mode
editableFields.forEach(field => {
const currentValue = field.textContent.trim();
const fieldName = field.dataset.fieldName;
const fieldType = field.dataset.fieldType || 'text';
// Create input field
let input;
if (fieldType === 'textarea') {
input = document.createElement('textarea');
input.value = currentValue;
input.rows = 4;
} else if (fieldType === 'select') {
input = document.createElement('select');
// Get options from data attribute
const options = JSON.parse(field.dataset.options || '[]');
options.forEach(option => {
const optionEl = document.createElement('option');
optionEl.value = option.value;
optionEl.textContent = option.label;
optionEl.selected = option.value === currentValue;
input.appendChild(optionEl);
});
} else if (fieldType === 'date') {
input = document.createElement('input');
input.type = 'date';
input.value = currentValue;
} else if (fieldType === 'number') {
input = document.createElement('input');
input.type = 'number';
input.value = currentValue;
if (field.dataset.min) input.min = field.dataset.min;
if (field.dataset.max) input.max = field.dataset.max;
if (field.dataset.step) input.step = field.dataset.step;
} else {
input = document.createElement('input');
input.type = fieldType;
input.value = currentValue;
}
input.className = 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white';
input.dataset.originalValue = currentValue;
input.dataset.fieldName = fieldName;
// Replace content with input
field.textContent = '';
field.appendChild(input);
});
// Show save/cancel buttons
const actionButtons = document.createElement('div');
actionButtons.className = 'flex gap-2 mt-2';
actionButtons.innerHTML = `
<button class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600" data-save-button>
<i class="mr-2 fas fa-save"></i>Save Changes
</button>
<button class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500" data-cancel-button>
<i class="mr-2 fas fa-times"></i>Cancel
</button>
${this.dataset.requireReason ? `
<div class="flex-grow">
<input type="text" class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Reason for changes (required)"
data-reason-input>
<input type="text" class="w-full mt-1 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Source (optional)"
data-source-input>
</div>
` : ''}
`;
const container = editableFields[0].closest('.editable-container');
container.appendChild(actionButtons);
// Hide edit button while editing
this.style.display = 'none';
});
});
// Handle form submissions
document.querySelectorAll('form[data-submit-type]').forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
const submitType = this.dataset.submitType;
const formData = new FormData(this);
const data = {};
formData.forEach((value, key) => {
data[key] = value;
});
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Submit form
fetch(this.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
submission_type: submitType,
...data
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showNotification(data.message, 'success');
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
} else {
showNotification(data.message, 'error');
}
})
.catch(error => {
showNotification('An error occurred while submitting the form.', 'error');
console.error('Error:', error);
});
});
});
// Handle save button clicks using event delegation
document.addEventListener('click', function(e) {
if (e.target.matches('[data-save-button]')) {
const container = e.target.closest('.editable-container');
const contentId = container.querySelector('[data-editable]').dataset.contentId;
const contentType = container.querySelector('[data-edit-button]').dataset.contentType;
const editableFields = container.querySelectorAll('[data-editable]');
// Collect changes
const changes = {};
editableFields.forEach(field => {
const input = field.querySelector('input, textarea, select');
if (input && input.value !== input.dataset.originalValue) {
changes[input.dataset.fieldName] = input.value;
}
});
// If no changes, just cancel
if (Object.keys(changes).length === 0) {
cancelEdit(container);
return;
}
// Get reason and source if required
const reasonInput = container.querySelector('[data-reason-input]');
const sourceInput = container.querySelector('[data-source-input]');
const reason = reasonInput ? reasonInput.value : '';
const source = sourceInput ? sourceInput.value : '';
// Validate reason if required
if (reasonInput && !reason) {
alert('Please provide a reason for your changes.');
return;
}
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Submit changes
fetch(window.location.pathname, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
content_type: contentType,
content_id: contentId,
changes,
reason,
source
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
if (data.auto_approved) {
// Update the display immediately
Object.entries(changes).forEach(([field, value]) => {
const element = container.querySelector(`[data-editable][data-field-name="${field}"]`);
if (element) {
element.textContent = value;
}
});
}
showNotification(data.message, 'success');
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
} else {
showNotification(data.message, 'error');
}
cancelEdit(container);
})
.catch(error => {
showNotification('An error occurred while saving changes.', 'error');
console.error('Error:', error);
cancelEdit(container);
});
}
});
// Handle cancel button clicks using event delegation
document.addEventListener('click', function(e) {
if (e.target.matches('[data-cancel-button]')) {
const container = e.target.closest('.editable-container');
cancelEdit(container);
}
});
});
function cancelEdit(container) {
// Restore original content
container.querySelectorAll('[data-editable]').forEach(field => {
const input = field.querySelector('input, textarea, select');
if (input) {
field.textContent = input.dataset.originalValue;
}
});
// Remove action buttons
const actionButtons = container.querySelector('.flex.gap-2');
if (actionButtons) {
actionButtons.remove();
}
// Show edit button
const editButton = container.querySelector('[data-edit-button]');
if (editButton) {
editButton.style.display = '';
}
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `fixed bottom-4 right-4 p-4 rounded-lg shadow-lg text-white ${
type === 'success' ? 'bg-green-600 dark:bg-green-500' :
type === 'error' ? 'bg-red-600 dark:bg-red-500' :
'bg-blue-600 dark:bg-blue-500'
}`;
notification.textContent = message;
document.body.appendChild(notification);
// Remove after 5 seconds
setTimeout(() => {
notification.remove();
}, 5000);
}

View File

@@ -1,81 +0,0 @@
function locationAutocomplete(field, filterParks = false) {
return {
query: '',
suggestions: [],
fetchSuggestions() {
let url;
const params = new URLSearchParams({
q: this.query,
filter_parks: filterParks
});
switch (field) {
case 'country':
url = '/parks/ajax/countries/';
break;
case 'region':
url = '/parks/ajax/regions/';
// Add country parameter if we're fetching regions
const countryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (countryInput && countryInput.value) {
params.append('country', countryInput.value);
}
break;
case 'city':
url = '/parks/ajax/cities/';
// Add country and region parameters if we're fetching cities
const regionInput = document.getElementById(filterParks ? 'region' : 'id_region_name');
const cityCountryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (regionInput && regionInput.value && cityCountryInput && cityCountryInput.value) {
params.append('country', cityCountryInput.value);
params.append('region', regionInput.value);
}
break;
}
if (url) {
fetch(`${url}?${params}`)
.then(response => response.json())
.then(data => {
this.suggestions = data;
});
}
},
selectSuggestion(suggestion) {
this.query = suggestion.name;
this.suggestions = [];
// If this is a form field (not filter), update hidden fields
if (!filterParks) {
const hiddenField = document.getElementById(`id_${field}`);
if (hiddenField) {
hiddenField.value = suggestion.id;
}
// Clear dependent fields when parent field changes
if (field === 'country') {
const regionInput = document.getElementById('id_region_name');
const cityInput = document.getElementById('id_city_name');
const regionHidden = document.getElementById('id_region');
const cityHidden = document.getElementById('id_city');
if (regionInput) regionInput.value = '';
if (cityInput) cityInput.value = '';
if (regionHidden) regionHidden.value = '';
if (cityHidden) cityHidden.value = '';
} else if (field === 'region') {
const cityInput = document.getElementById('id_city_name');
const cityHidden = document.getElementById('id_city');
if (cityInput) cityInput.value = '';
if (cityHidden) cityHidden.value = '';
}
}
// Trigger form submission for filters
if (filterParks) {
htmx.trigger('#park-filters', 'change');
}
}
};
}

View File

@@ -1,79 +1,40 @@
// Initialize dark mode from localStorage // Theme Toggle
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Check if dark mode was previously enabled const themeToggle = document.getElementById('theme-toggle');
const darkMode = localStorage.getItem('darkMode') === 'true'; const themeIcon = themeToggle.nextElementSibling.querySelector('i');
if (darkMode) {
document.documentElement.classList.add('dark');
}
});
// Handle search form submission // Set initial icon
document.addEventListener('submit', (e) => { updateThemeIcon();
if (e.target.matches('form[action*="search"]')) {
const searchInput = e.target.querySelector('input[name="q"]'); themeToggle.addEventListener('change', () => {
if (!searchInput.value.trim()) { if (document.documentElement.classList.contains('dark')) {
e.preventDefault(); 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');
} }
});
// Close mobile menu when clicking outside // Mobile Menu Toggle
document.addEventListener('click', (e) => { const mobileMenuBtn = document.getElementById('mobileMenuBtn');
const mobileMenu = document.querySelector('[x-show="mobileMenuOpen"]'); const mobileMenu = document.getElementById('mobileMenu');
const menuButton = document.querySelector('[x-on\\:click="mobileMenuOpen = !mobileMenuOpen"]'); const menuIcon = mobileMenuBtn.querySelector('i');
if (mobileMenu && menuButton && !mobileMenu.contains(e.target) && !menuButton.contains(e.target)) { mobileMenu.style.display = 'none';
const alpineData = mobileMenu._x_dataStack && mobileMenu._x_dataStack[0]; let isMenuOpen = false;
if (alpineData && alpineData.mobileMenuOpen) {
alpineData.mobileMenuOpen = false;
}
}
});
// Handle flash messages mobileMenuBtn.addEventListener('click', () => {
document.addEventListener('DOMContentLoaded', () => { isMenuOpen = !isMenuOpen;
const alerts = document.querySelectorAll('.alert'); mobileMenu.style.display = isMenuOpen ? 'block' : 'none';
alerts.forEach(alert => { menuIcon.classList.remove('fa-bars', 'fa-times');
setTimeout(() => { menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
alert.style.opacity = '0';
setTimeout(() => alert.remove(), 300);
}, 5000);
});
});
// Initialize tooltips
document.addEventListener('DOMContentLoaded', () => {
const tooltips = document.querySelectorAll('[data-tooltip]');
tooltips.forEach(tooltip => {
tooltip.addEventListener('mouseenter', (e) => {
const text = e.target.getAttribute('data-tooltip');
const tooltipEl = document.createElement('div');
tooltipEl.className = 'tooltip bg-gray-900 text-white px-2 py-1 rounded text-sm absolute z-50';
tooltipEl.textContent = text;
document.body.appendChild(tooltipEl);
const rect = e.target.getBoundingClientRect();
tooltipEl.style.top = rect.bottom + 5 + 'px';
tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px';
});
tooltip.addEventListener('mouseleave', () => {
const tooltips = document.querySelectorAll('.tooltip');
tooltips.forEach(t => t.remove());
});
});
});
// Handle dropdown menus
document.addEventListener('click', (e) => {
const dropdowns = document.querySelectorAll('[x-show]');
dropdowns.forEach(dropdown => {
if (!dropdown.contains(e.target) &&
!e.target.matches('[x-on\\:click*="open = !open"]')) {
const alpineData = dropdown._x_dataStack && dropdown._x_dataStack[0];
if (alpineData && alpineData.open) {
alpineData.open = false;
}
}
}); });
}); });

View File

@@ -1,28 +0,0 @@
let parkMap = null;
function initParkMap(latitude, longitude, name) {
const mapContainer = document.getElementById('park-map');
// Only initialize if container exists and map hasn't been initialized
if (mapContainer && !parkMap) {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
parkMap = L.map('park-map').setView([latitude, longitude], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(parkMap);
L.marker([latitude, longitude])
.addTo(parkMap)
.bindPopup(name);
// Update map size when window is resized
window.addEventListener('resize', function() {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
parkMap.invalidateSize();
});
}
}

View File

@@ -1,91 +0,0 @@
document.addEventListener('alpine:init', () => {
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
photos,
fullscreenPhoto: null,
uploading: false,
uploadProgress: 0,
error: null,
showSuccess: false,
showFullscreen(photo) {
this.fullscreenPhoto = photo;
},
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) {
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
this.showSuccess = false;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
break;
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
if (!this.error) {
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
},
async sharePhoto(photo) {
if (navigator.share) {
try {
await navigator.share({
title: photo.caption || 'Shared photo',
url: photo.url
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
} else {
// Fallback: copy URL to clipboard
navigator.clipboard.writeText(photo.url)
.then(() => alert('Photo URL copied to clipboard!'))
.catch(err => console.error('Error copying to clipboard:', err));
}
}
}));
});

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> <script src="https://unpkg.com/htmx.org@1.9.6"></script>
<!-- Alpine.js --> <!-- 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 --> <!-- Location Autocomplete -->
<script src="{% static 'js/location-autocomplete.js' %}"></script> <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 django.views.generic import TemplateView
from .views import HomeView, SearchView from .views import HomeView, SearchView
from . import views from . import views
from autocomplete.urls import urlpatterns as autocomplete_patterns
import os import os
urlpatterns = [ urlpatterns = [
@@ -58,6 +59,8 @@ urlpatterns = [
views***REMOVED***ironment_and_settings_view, views***REMOVED***ironment_and_settings_view,
name="environment_and_settings", name="environment_and_settings",
), ),
# Autocomplete URLs
path("ac/", include((autocomplete_patterns, "autocomplete"), namespace="autocomplete")),
] ]
# Serve static files in development # Serve static files in development