Compare commits

..

1 Commits

Author SHA1 Message Date
pixeebot[bot]
e3901587b7 Secure Setting for Django SESSION_COOKIE_SECURE flag 2025-04-27 03:28:04 +00:00
39 changed files with 1147 additions and 844 deletions

View File

@@ -27,17 +27,4 @@ This applies to all management commands including but not limited to:
- Creating superuser: `uv run manage.py createsuperuser` - Creating superuser: `uv run manage.py createsuperuser`
- 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

@@ -1,49 +0,0 @@
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)

View File

@@ -1,25 +0,0 @@
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

@@ -1,20 +0,0 @@
{% 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

@@ -1,38 +0,0 @@
{% 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>

View File

@@ -1,9 +0,0 @@
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'),
]

View File

@@ -1,52 +0,0 @@
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

@@ -1,61 +0,0 @@
# 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,23 +21,6 @@
- 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

View File

@@ -1,15 +0,0 @@
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,13 +1,14 @@
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 ModelAutocomplete, AutocompleteWidget from autocomplete import 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(ModelAutocomplete): class ParkAutocomplete(BaseAutocomplete):
"""Autocomplete for searching parks. """Autocomplete for searching parks.
Features: Features:
@@ -18,8 +19,6 @@ class ParkAutocomplete(ModelAutocomplete):
""" """
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

@@ -47,23 +47,68 @@
{% 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="bg-white shadow sm:rounded-lg"> <div class="w-full relative"
<div class="px-4 py-5 sm:p-6"> x-data="{ query: '', selectedId: null }"
<form id="filter-form" @search-selected.window="
x-ref="filterForm" query = $event.detail;
hx-get="{% url 'parks:park_list' %}" selectedId = $event.target.value;
hx-target="#park-results" $refs.filterForm.querySelector('input[name=search]').value = query;
hx-push-url="true" $refs.filterForm.submit();
hx-trigger="submit" query = '';
class="mt-4"> ">
<div class="mb-6"> <form hx-get="{% url 'parks:suggest_parks' %}"
{{ search_form }} hx-target="#search-results"
hx-trigger="input changed delay:300ms"
hx-indicator="#search-indicator"
x-ref="searchForm">
<div class="relative">
<input type="search"
name="search"
placeholder="Search parks..."
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
aria-label="Search parks"
aria-controls="search-results"
:aria-expanded="query !== ''"
x-model="query"
@keydown.escape="query = ''">
<!-- Loading indicator -->
<div id="search-indicator"
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results">
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span class="sr-only">Searching...</span>
</div> </div>
{% include "search/components/filter_form.html" with filter=filter %} </div>
</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,21 +1,37 @@
{% if results %} {% load filter_utils %}
<div class="py-1"> {% if suggestions %}
{% for result in results %} <div id="search-suggestions-results"
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700" class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
@click="$dispatch('search-selected', '{{ result.name }}')" x-show="open"
value="{{ result.id }}" x-cloak
role="option"> @keydown.escape.window="open = false"
<div class="flex flex-col"> x-transition:enter="transition ease-out duration-100"
<span class="font-medium text-gray-900 dark:text-white">{{ result.name }}</span> x-transition:enter-start="transform opacity-0 scale-95"
<span class="text-sm text-gray-500 dark:text-gray-400"> x-transition:enter-end="transform opacity-100 scale-100"
{{ result.status }}{% if result.location %} • {{ result.location }}{% endif %} x-transition:leave="transition ease-in duration-75"
</span> x-transition:leave-start="transform opacity-100 scale-100"
</div> x-transition:leave-end="transform opacity-0 scale-95">
</button> {% for park in suggestions %}
{% endfor %} {% with location=park.location.first %}
</div> <button type="button"
{% else %} 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"
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400"> :class="{ 'bg-blue-50': focusedIndex === {{ forloop.counter0 }} }"
{% if query %}No parks found matching "{{ query }}"{% else %}Start typing to search parks{% endif %} @mousedown.prevent="query = '{{ park.name }}'; $refs.search.value = '{{ park.name }}'"
</div> @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>
{% endif %} {% endif %}

View File

@@ -18,6 +18,8 @@ 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

@@ -1,4 +1,5 @@
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse, JsonResponse
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
@@ -12,8 +13,6 @@ 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
@@ -21,11 +20,35 @@ 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
# Get filtered queryset # Apply search if park ID selected via autocomplete
queryset = filter_instance.qs park_id = self.request.GET.get('park')
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

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

View File

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

5
static/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

5
static/js/cdn.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,50 +1,81 @@
document.addEventListener('DOMContentLoaded', () => { function locationAutocomplete(field, filterParks = false) {
const countryInput = document.querySelector('[name="country"]'); return {
const regionInput = document.querySelector('[name="region"]'); query: '',
const cityInput = document.querySelector('[name="city"]'); suggestions: [],
fetchSuggestions() {
if (!countryInput || !regionInput || !cityInput) return; let url;
const params = new URLSearchParams({
// Update regions when country changes q: this.query,
countryInput.addEventListener('change', () => { filter_parks: filterParks
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) { switch (field) {
fetch(`/location/cities/?country=${encodeURIComponent(country)}&region=${encodeURIComponent(region)}`) case 'country':
.then(response => response.json()) url = '/parks/ajax/countries/';
.then(data => { break;
cityInput.innerHTML = '<option value="">Select a city</option>'; case 'region':
data.cities.forEach(city => { url = '/parks/ajax/regions/';
const option = new Option(city, city); // Add country parameter if we're fetching regions
cityInput.add(option); 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,40 +1,141 @@
// Theme Toggle // Theme handling
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle'); const themeToggle = document.getElementById('theme-toggle');
const themeIcon = themeToggle.nextElementSibling.querySelector('i'); const html = document.documentElement;
// Set initial icon // Initialize toggle state based on current theme
updateThemeIcon(); if (themeToggle) {
themeToggle.checked = html.classList.contains('dark');
themeToggle.addEventListener('change', () => {
if (document.documentElement.classList.contains('dark')) { // Handle toggle changes
document.documentElement.classList.remove('dark'); themeToggle.addEventListener('change', function() {
localStorage.setItem('theme', 'light'); const isDark = this.checked;
} else { html.classList.toggle('dark', isDark);
document.documentElement.classList.add('dark'); localStorage.setItem('theme', isDark ? 'dark' : 'light');
localStorage.setItem('theme', 'dark'); });
}
updateThemeIcon(); // Listen for system theme changes
}); const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
function updateThemeIcon() { if (!localStorage.getItem('theme')) {
const isDark = document.documentElement.classList.contains('dark'); const isDark = e.matches;
themeIcon.classList.remove('fa-sun', 'fa-moon'); html.classList.toggle('dark', isDark);
themeIcon.classList.add(isDark ? 'fa-sun' : 'fa-moon'); themeToggle.checked = isDark;
}
});
} }
});
// Mobile Menu Toggle // 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();
}
}
});
// Mobile menu toggle with transitions
document.addEventListener('DOMContentLoaded', () => {
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');
mobileMenu.style.display = 'none'; if (mobileMenuBtn && mobileMenu) {
let isMenuOpen = false; let isMenuOpen = false;
mobileMenuBtn.addEventListener('click', () => { const toggleMenu = () => {
isMenuOpen = !isMenuOpen; isMenuOpen = !isMenuOpen;
mobileMenu.style.display = isMenuOpen ? 'block' : 'none'; mobileMenu.classList.toggle('show', isMenuOpen);
menuIcon.classList.remove('fa-bars', 'fa-times'); mobileMenuBtn.setAttribute('aria-expanded', isMenuOpen.toString());
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());
});
});
});

29
static/js/park-map.js Normal file
View File

@@ -0,0 +1,29 @@
// 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

@@ -0,0 +1,91 @@
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));
}
}
}));
});

42
static/js/search.js Normal file
View File

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

View File

@@ -2181,18 +2181,6 @@ 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;
} }
@@ -2469,10 +2457,6 @@ select {
display: none; display: none;
} }
.h-10 {
height: 2.5rem;
}
.h-16 { .h-16 {
height: 4rem; height: 4rem;
} }
@@ -2501,10 +2485,6 @@ select {
height: 1.25rem; height: 1.25rem;
} }
.h-6 {
height: 1.5rem;
}
.h-8 { .h-8 {
height: 2rem; height: 2rem;
} }
@@ -2553,10 +2533,6 @@ select {
width: 1.25rem; width: 1.25rem;
} }
.w-6 {
width: 1.5rem;
}
.w-64 { .w-64 {
width: 16rem; width: 16rem;
} }
@@ -2670,16 +2646,6 @@ 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);
@@ -3034,6 +3000,10 @@ 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));
@@ -3274,10 +3244,6 @@ select {
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.pt-2 {
padding-top: 0.5rem;
}
.text-left { .text-left {
text-align: left; text-align: left;
} }
@@ -3369,11 +3335,6 @@ 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));
@@ -3444,11 +3405,6 @@ 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));
@@ -3551,11 +3507,6 @@ 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);
@@ -3571,19 +3522,6 @@ 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);
} }
@@ -3858,11 +3796,6 @@ 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;
} }
@@ -3891,10 +3824,6 @@ 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;
} }
@@ -4001,10 +3930,6 @@ 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));
@@ -4043,11 +3968,6 @@ 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));
@@ -4270,11 +4190,6 @@ 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;
@@ -4382,26 +4297,10 @@ 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,21 +1,18 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', function() {
// Get all alert elements
const alerts = document.querySelectorAll('.alert'); const alerts = document.querySelectorAll('.alert');
// For each alert
alerts.forEach(alert => { alerts.forEach(alert => {
// Auto-hide alerts after 5 seconds // After 5 seconds
setTimeout(() => { setTimeout(() => {
alert.classList.add('fade-out'); // Add slideOut animation
alert.style.animation = 'slideOut 0.5s ease-out forwards';
// Remove the alert after animation completes
setTimeout(() => { setTimeout(() => {
alert.remove(); alert.remove();
}, 300); // Match CSS transition duration }, 500);
}, 5000); }, 5000);
// Add click-to-dismiss functionality
alert.addEventListener('click', () => {
alert.classList.add('fade-out');
setTimeout(() => {
alert.remove();
}, 300);
});
}); });
}); });

5
staticfiles/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

5
staticfiles/js/cdn.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,262 @@
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

@@ -0,0 +1,81 @@
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,40 +1,79 @@
// Theme Toggle // Initialize dark mode from localStorage
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle'); // Check if dark mode was previously enabled
const themeIcon = themeToggle.nextElementSibling.querySelector('i'); const darkMode = localStorage.getItem('darkMode') === 'true';
if (darkMode) {
// Set initial icon document.documentElement.classList.add('dark');
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 // Handle search form submission
const mobileMenuBtn = document.getElementById('mobileMenuBtn'); document.addEventListener('submit', (e) => {
const mobileMenu = document.getElementById('mobileMenu'); if (e.target.matches('form[action*="search"]')) {
const menuIcon = mobileMenuBtn.querySelector('i'); const searchInput = e.target.querySelector('input[name="q"]');
if (!searchInput.value.trim()) {
e.preventDefault();
}
}
});
mobileMenu.style.display = 'none'; // Close mobile menu when clicking outside
let isMenuOpen = false; document.addEventListener('click', (e) => {
const mobileMenu = document.querySelector('[x-show="mobileMenuOpen"]');
const menuButton = document.querySelector('[x-on\\:click="mobileMenuOpen = !mobileMenuOpen"]');
if (mobileMenu && menuButton && !mobileMenu.contains(e.target) && !menuButton.contains(e.target)) {
const alpineData = mobileMenu._x_dataStack && mobileMenu._x_dataStack[0];
if (alpineData && alpineData.mobileMenuOpen) {
alpineData.mobileMenuOpen = false;
}
}
});
mobileMenuBtn.addEventListener('click', () => { // Handle flash messages
isMenuOpen = !isMenuOpen; document.addEventListener('DOMContentLoaded', () => {
mobileMenu.style.display = isMenuOpen ? 'block' : 'none'; const alerts = document.querySelectorAll('.alert');
menuIcon.classList.remove('fa-bars', 'fa-times'); alerts.forEach(alert => {
menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars'); 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 = '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

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,91 @@
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,134 +0,0 @@
/* 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

@@ -1,69 +0,0 @@
// 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="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="{% static 'js/alpine.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

@@ -226,3 +226,4 @@ TAILWIND_CLI_DIST_CSS = os.path.join(BASE_DIR, "static/css/tailwind.css")
TURNSTILE_SITE_KEY = "0x4AAAAAAAyqVp3RjccrC9Kz" TURNSTILE_SITE_KEY = "0x4AAAAAAAyqVp3RjccrC9Kz"
TURNSTILE_SECRET_KEY = "0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY" TURNSTILE_SECRET_KEY = "0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY"
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
SESSION_COOKIE_SECURE = True

View File

@@ -7,7 +7,6 @@ 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 = [
@@ -59,8 +58,6 @@ 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