mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 16:11:08 -05:00
Compare commits
1 Commits
remove-cus
...
pixeebot/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3901587b7 |
15
.clinerules
15
.clinerules
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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'),
|
|
||||||
]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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
5
static/js/cdn.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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)}®ion=${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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
29
static/js/park-map.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
91
static/js/photo-gallery.js
Normal file
91
static/js/photo-gallery.js
Normal 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
42
static/js/search.js
Normal 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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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
5
staticfiles/js/cdn.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
262
staticfiles/js/inline-edit.js
Normal file
262
staticfiles/js/inline-edit.js
Normal 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);
|
||||||
|
}
|
||||||
81
staticfiles/js/location-autocomplete.js
Normal file
81
staticfiles/js/location-autocomplete.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
28
staticfiles/js/park-map.js
Normal file
28
staticfiles/js/park-map.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
91
staticfiles/js/photo-gallery.js
Normal file
91
staticfiles/js/photo-gallery.js
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user