mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 18:31:09 -05:00
Compare commits
3 Commits
pixeebot/d
...
remove-cus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1876af46d9 | ||
|
|
8e9b6b6a15 | ||
|
|
45948bcc80 |
13
.clinerules
13
.clinerules
@@ -28,3 +28,16 @@ This applies to all management commands including but not limited to:
|
|||||||
- Starting shell: `uv run manage.py shell`
|
- Starting shell: `uv run manage.py shell`
|
||||||
|
|
||||||
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
|
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
|
||||||
|
|
||||||
|
## Static Files Management
|
||||||
|
IMPORTANT: All static files must be placed in the `static/` directory, not `staticfiles/`. The `staticfiles/` directory is reserved for Django's collectstatic command output and should not be used directly.
|
||||||
|
|
||||||
|
This consolidation:
|
||||||
|
1. Follows Django best practices of separating source static files from collected files
|
||||||
|
2. Prevents confusion between development and production static file locations
|
||||||
|
3. Makes it clear which static files are part of the source code (static/) versus compiled/collected (staticfiles/)
|
||||||
|
|
||||||
|
When adding new static files:
|
||||||
|
- Add them to `static/` directory
|
||||||
|
- Use Django's `static` template tag to reference them
|
||||||
|
- Run `uv run manage.py collectstatic` when deploying
|
||||||
49
autocomplete/__init__.py
Normal file
49
autocomplete/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
default_app_config = 'autocomplete.apps.AutocompleteConfig'
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.forms.widgets import Widget
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
|
||||||
|
class ModelAutocomplete:
|
||||||
|
"""Base class for model-based autocomplete."""
|
||||||
|
model = None # Model class to use for autocomplete
|
||||||
|
search_attrs = [] # List of model attributes to search
|
||||||
|
minimum_search_length = 2 # Minimum length of search string
|
||||||
|
max_results = 10 # Maximum number of results to return
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not self.model:
|
||||||
|
raise ImproperlyConfigured("ModelAutocomplete requires a model class")
|
||||||
|
if not self.search_attrs:
|
||||||
|
raise ImproperlyConfigured("ModelAutocomplete requires search_attrs")
|
||||||
|
|
||||||
|
def get_search_results(self, search):
|
||||||
|
"""Return search results for a given search string."""
|
||||||
|
raise NotImplementedError("Subclasses must implement get_search_results()")
|
||||||
|
|
||||||
|
def format_result(self, obj):
|
||||||
|
"""Format a single result object."""
|
||||||
|
raise NotImplementedError("Subclasses must implement format_result()")
|
||||||
|
|
||||||
|
|
||||||
|
class AutocompleteWidget(Widget):
|
||||||
|
"""Widget for autocomplete fields."""
|
||||||
|
template_name = 'autocomplete/widget.html'
|
||||||
|
|
||||||
|
def __init__(self, ac_class, attrs=None):
|
||||||
|
super().__init__(attrs)
|
||||||
|
if not issubclass(ac_class, ModelAutocomplete):
|
||||||
|
raise ImproperlyConfigured("ac_class must be a subclass of ModelAutocomplete")
|
||||||
|
self.ac_class = ac_class
|
||||||
|
|
||||||
|
def get_context(self, name, value, attrs):
|
||||||
|
context = super().get_context(name, value, attrs)
|
||||||
|
# Add ac_name for URL resolution
|
||||||
|
context['ac_name'] = self.ac_class.__name__.lower()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
|
context = self.get_context(name, value, attrs)
|
||||||
|
return render_to_string(self.template_name, context)
|
||||||
25
autocomplete/apps.py
Normal file
25
autocomplete/apps.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AutocompleteConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'autocomplete'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._registry = {}
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
"""Register all autocomplete classes."""
|
||||||
|
from parks.forms import ParkAutocomplete
|
||||||
|
|
||||||
|
# Register autocomplete classes
|
||||||
|
self.register_autocomplete('park', ParkAutocomplete)
|
||||||
|
|
||||||
|
def register_autocomplete(self, name, ac_class):
|
||||||
|
"""Register an autocomplete class."""
|
||||||
|
self._registry[name] = ac_class
|
||||||
|
|
||||||
|
def get_autocomplete_class(self, name):
|
||||||
|
"""Get an autocomplete class by name."""
|
||||||
|
return self._registry.get(name)
|
||||||
20
autocomplete/templates/autocomplete/suggestions.html
Normal file
20
autocomplete/templates/autocomplete/suggestions.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{% if results %}
|
||||||
|
<ul class="py-1 overflow-auto max-h-60" role="listbox">
|
||||||
|
{% for result in results %}
|
||||||
|
<li class="px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
|
||||||
|
role="option"
|
||||||
|
@click="selectedId = '{{ result.key }}'; query = '{{ result.label }}'; $refs.filterForm.requestSubmit()">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-medium">{{ result.label }}</span>
|
||||||
|
{% if result.extra %}
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ result.extra }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-4 py-2 text-gray-500 dark:text-gray-400">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
38
autocomplete/templates/autocomplete/widget.html
Normal file
38
autocomplete/templates/autocomplete/widget.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<div class="relative" x-data="{ query: '', selectedId: null }">
|
||||||
|
<input type="text"
|
||||||
|
name="{{ widget.name }}_search"
|
||||||
|
placeholder="{{ widget.attrs.placeholder|default:'Search...' }}"
|
||||||
|
class="{{ widget.attrs.class }}"
|
||||||
|
x-model="query"
|
||||||
|
@keydown.escape="query = ''"
|
||||||
|
hx-get="{% url 'autocomplete:items' ac_name %}"
|
||||||
|
hx-trigger="input changed delay:300ms"
|
||||||
|
hx-target="#{{ widget.name }}-suggestions"
|
||||||
|
hx-indicator="#{{ widget.name }}-indicator">
|
||||||
|
|
||||||
|
<input type="hidden"
|
||||||
|
name="{{ widget.name }}"
|
||||||
|
x-model="selectedId">
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div id="{{ widget.name }}-indicator"
|
||||||
|
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading search results">
|
||||||
|
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Searching...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suggestions dropdown -->
|
||||||
|
<div id="{{ widget.name }}-suggestions"
|
||||||
|
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
|
||||||
|
role="listbox"
|
||||||
|
style="display: none;"
|
||||||
|
x-show="query.length > 0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
9
autocomplete/urls.py
Normal file
9
autocomplete/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'autocomplete'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('<str:ac_name>/items/', views.items, name='items'),
|
||||||
|
path('<str:ac_name>/toggle/', views.toggle, name='toggle'),
|
||||||
|
]
|
||||||
52
autocomplete/views.py
Normal file
52
autocomplete/views.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from django.http import JsonResponse, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, render
|
||||||
|
from django.apps import apps
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
def items(request, ac_name):
|
||||||
|
"""Return autocomplete items for a given autocomplete class."""
|
||||||
|
try:
|
||||||
|
# Get the autocomplete class from the registry
|
||||||
|
ac_class = apps.get_app_config('autocomplete').get_autocomplete_class(ac_name)
|
||||||
|
if not ac_class:
|
||||||
|
raise ImproperlyConfigured(f"No autocomplete class found for {ac_name}")
|
||||||
|
|
||||||
|
# Create instance and get results
|
||||||
|
ac = ac_class()
|
||||||
|
search = request.GET.get('search', '')
|
||||||
|
|
||||||
|
# Check minimum search length
|
||||||
|
if len(search) < ac.minimum_search_length:
|
||||||
|
return HttpResponse('')
|
||||||
|
|
||||||
|
# Get and format results
|
||||||
|
results = ac.get_search_results(search)[:ac.max_results]
|
||||||
|
formatted_results = [ac.format_result(obj) for obj in results]
|
||||||
|
|
||||||
|
# Render suggestions template
|
||||||
|
return render(request, 'autocomplete/suggestions.html', {
|
||||||
|
'results': formatted_results
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return HttpResponse(str(e), status=400)
|
||||||
|
|
||||||
|
def toggle(request, ac_name):
|
||||||
|
"""Toggle selection state for an autocomplete item."""
|
||||||
|
try:
|
||||||
|
# Get the autocomplete class from the registry
|
||||||
|
ac_class = apps.get_app_config('autocomplete').get_autocomplete_class(ac_name)
|
||||||
|
if not ac_class:
|
||||||
|
raise ImproperlyConfigured(f"No autocomplete class found for {ac_name}")
|
||||||
|
|
||||||
|
# Create instance and handle toggle
|
||||||
|
ac = ac_class()
|
||||||
|
item_id = request.POST.get('id')
|
||||||
|
if not item_id:
|
||||||
|
raise ValueError("No item ID provided")
|
||||||
|
|
||||||
|
# Get the object and format it
|
||||||
|
obj = get_object_or_404(ac.model, pk=item_id)
|
||||||
|
result = ac.format_result(obj)
|
||||||
|
return JsonResponse(result)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=400)
|
||||||
61
memory-bank/decisions/search_duplication_fix.md
Normal file
61
memory-bank/decisions/search_duplication_fix.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Search Duplication Fix
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
The park search was showing duplicate results because:
|
||||||
|
1. There were two separate forms with the same ID ("filter-form")
|
||||||
|
2. Both forms were targeting the same element ("#park-results")
|
||||||
|
3. The search form and filter form were operating independently
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
1. Created a custom autocomplete package to handle search functionality:
|
||||||
|
- ModelAutocomplete base class for model-based autocomplete
|
||||||
|
- AutocompleteWidget for rendering the search input
|
||||||
|
- Templates for widget and suggestions
|
||||||
|
- Views for handling search and selection
|
||||||
|
|
||||||
|
2. Updated ParkAutocomplete to use ModelAutocomplete:
|
||||||
|
```python
|
||||||
|
class ParkAutocomplete(ModelAutocomplete):
|
||||||
|
model = Park
|
||||||
|
search_attrs = ['name']
|
||||||
|
minimum_search_length = 2
|
||||||
|
max_results = 8
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Combined search and filter functionality into a single form:
|
||||||
|
```html
|
||||||
|
<form id="filter-form"
|
||||||
|
x-ref="filterForm"
|
||||||
|
hx-get="{% url 'parks:park_list' %}"
|
||||||
|
hx-target="#park-results"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-trigger="submit"
|
||||||
|
class="mt-4">
|
||||||
|
<div class="mb-6">
|
||||||
|
{{ search_form }} <!-- AutocompleteWidget -->
|
||||||
|
</div>
|
||||||
|
{% include "search/components/filter_form.html" with filter=filter %}
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Added proper URL routing for autocomplete:
|
||||||
|
```python
|
||||||
|
path("ac/", include((autocomplete_patterns, "autocomplete"), namespace="autocomplete"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
1. No more duplicate search requests
|
||||||
|
2. Cleaner template structure
|
||||||
|
3. Better user experience with a single search interface
|
||||||
|
4. Proper integration with django-htmx-autocomplete
|
||||||
|
5. Simplified view logic
|
||||||
|
6. Reusable autocomplete functionality for other models
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
- Using django-htmx-autocomplete's AutocompleteWidget for search
|
||||||
|
- Single form submission handles both search and filtering
|
||||||
|
- HTMX handles the dynamic updates
|
||||||
|
- View mode selection preserved during search/filter operations
|
||||||
|
- Minimum search length of 2 characters
|
||||||
|
- Maximum of 8 search results
|
||||||
|
- Search results include park status and location
|
||||||
@@ -21,6 +21,23 @@
|
|||||||
- Implement component-based structure
|
- Implement component-based structure
|
||||||
- Follow progressive enhancement
|
- Follow progressive enhancement
|
||||||
|
|
||||||
|
### Static Files Organization
|
||||||
|
1. Directory Structure
|
||||||
|
- `static/` - Source static files (CSS, JS, images, etc.)
|
||||||
|
- `staticfiles/` - Collected files (generated by collectstatic)
|
||||||
|
|
||||||
|
2. File Management Rules
|
||||||
|
- Place all source static files in `static/` directory
|
||||||
|
- Never directly modify `staticfiles/` directory
|
||||||
|
- Use Django's `static` template tag for references
|
||||||
|
- Run collectstatic before deployment
|
||||||
|
|
||||||
|
3. Benefits of Separation
|
||||||
|
- Clear distinction between source and compiled files
|
||||||
|
- Prevents confusion in development vs production
|
||||||
|
- Follows Django best practices
|
||||||
|
- Simplifies deployment process
|
||||||
|
|
||||||
## Design Patterns
|
## Design Patterns
|
||||||
|
|
||||||
### Data Access
|
### Data Access
|
||||||
|
|||||||
15
parks/autocomplete.py
Normal file
15
parks/autocomplete.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from autocomplete import ModelAutocomplete
|
||||||
|
from .models import Park
|
||||||
|
|
||||||
|
|
||||||
|
class ParkAutocomplete(ModelAutocomplete):
|
||||||
|
"""Autocomplete class for Park model."""
|
||||||
|
model = Park
|
||||||
|
search_attrs = ['name', 'city', 'state', 'country'] # Fields to search
|
||||||
|
minimum_search_length = 2 # Start searching after 2 characters
|
||||||
|
max_results = 8 # Limit to 8 suggestions
|
||||||
|
|
||||||
|
# Customize display text
|
||||||
|
no_result_text = "No parks found matching your search."
|
||||||
|
narrow_search_text = "Showing %(page_size)s of %(total)s parks. Try narrowing your search."
|
||||||
|
type_at_least_n_characters = "Type at least %(n)s characters to search parks"
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
||||||
from autocomplete import AutocompleteWidget
|
from autocomplete import ModelAutocomplete, AutocompleteWidget
|
||||||
|
|
||||||
from core.forms import BaseAutocomplete
|
|
||||||
from .models import Park
|
from .models import Park
|
||||||
from location.models import Location
|
from location.models import Location
|
||||||
from .querysets import get_base_park_queryset
|
from .querysets import get_base_park_queryset
|
||||||
|
|
||||||
|
|
||||||
class ParkAutocomplete(BaseAutocomplete):
|
class ParkAutocomplete(ModelAutocomplete):
|
||||||
"""Autocomplete for searching parks.
|
"""Autocomplete for searching parks.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
@@ -19,6 +18,8 @@ class ParkAutocomplete(BaseAutocomplete):
|
|||||||
"""
|
"""
|
||||||
model = Park
|
model = Park
|
||||||
search_attrs = ['name'] # We'll match on park names
|
search_attrs = ['name'] # We'll match on park names
|
||||||
|
minimum_search_length = 2 # Start searching after 2 characters
|
||||||
|
max_results = 8 # Limit to 8 suggestions
|
||||||
|
|
||||||
def get_search_results(self, search):
|
def get_search_results(self, search):
|
||||||
"""Return search results with related data."""
|
"""Return search results with related data."""
|
||||||
|
|||||||
@@ -47,69 +47,24 @@
|
|||||||
{% block filter_section %}
|
{% block filter_section %}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="max-w-3xl mx-auto relative mb-8">
|
<div class="max-w-3xl mx-auto relative mb-8">
|
||||||
<div class="w-full relative"
|
|
||||||
x-data="{ query: '', selectedId: null }"
|
|
||||||
@search-selected.window="
|
|
||||||
query = $event.detail;
|
|
||||||
selectedId = $event.target.value;
|
|
||||||
$refs.filterForm.querySelector('input[name=search]').value = query;
|
|
||||||
$refs.filterForm.submit();
|
|
||||||
query = '';
|
|
||||||
">
|
|
||||||
<form hx-get="{% url 'parks:suggest_parks' %}"
|
|
||||||
hx-target="#search-results"
|
|
||||||
hx-trigger="input changed delay:300ms"
|
|
||||||
hx-indicator="#search-indicator"
|
|
||||||
x-ref="searchForm">
|
|
||||||
<div class="relative">
|
|
||||||
<input type="search"
|
|
||||||
name="search"
|
|
||||||
placeholder="Search parks..."
|
|
||||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
aria-label="Search parks"
|
|
||||||
aria-controls="search-results"
|
|
||||||
:aria-expanded="query !== ''"
|
|
||||||
x-model="query"
|
|
||||||
@keydown.escape="query = ''">
|
|
||||||
|
|
||||||
<!-- Loading indicator -->
|
|
||||||
<div id="search-indicator"
|
|
||||||
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading search results">
|
|
||||||
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Searching...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="search-results"
|
|
||||||
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
|
|
||||||
role="listbox">
|
|
||||||
<!-- Search suggestions will be loaded here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<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"
|
<form id="filter-form"
|
||||||
x-ref="filterForm"
|
x-ref="filterForm"
|
||||||
hx-get="{% url 'parks:park_list' %}"
|
hx-get="{% url 'parks:park_list' %}"
|
||||||
hx-target="#park-results"
|
hx-target="#park-results"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-trigger="change, submit"
|
hx-trigger="submit"
|
||||||
class="mt-4">
|
class="mt-4">
|
||||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
<div class="mb-6">
|
||||||
|
{{ search_form }}
|
||||||
|
</div>
|
||||||
{% include "search/components/filter_form.html" with filter=filter %}
|
{% include "search/components/filter_form.html" with filter=filter %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block results_list %}
|
{% block results_list %}
|
||||||
|
|||||||
@@ -1,37 +1,21 @@
|
|||||||
{% load filter_utils %}
|
{% if results %}
|
||||||
{% if suggestions %}
|
<div class="py-1">
|
||||||
<div id="search-suggestions-results"
|
{% for result in results %}
|
||||||
class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
|
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
x-show="open"
|
@click="$dispatch('search-selected', '{{ result.name }}')"
|
||||||
x-cloak
|
value="{{ result.id }}"
|
||||||
@keydown.escape.window="open = false"
|
role="option">
|
||||||
x-transition:enter="transition ease-out duration-100"
|
<div class="flex flex-col">
|
||||||
x-transition:enter-start="transform opacity-0 scale-95"
|
<span class="font-medium text-gray-900 dark:text-white">{{ result.name }}</span>
|
||||||
x-transition:enter-end="transform opacity-100 scale-100"
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
x-transition:leave="transition ease-in duration-75"
|
{{ result.status }}{% if result.location %} • {{ result.location }}{% endif %}
|
||||||
x-transition:leave-start="transform opacity-100 scale-100"
|
|
||||||
x-transition:leave-end="transform opacity-0 scale-95">
|
|
||||||
{% for park in suggestions %}
|
|
||||||
{% with location=park.location.first %}
|
|
||||||
<button type="button"
|
|
||||||
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between gap-2 transition duration-150"
|
|
||||||
:class="{ 'bg-blue-50': focusedIndex === {{ forloop.counter0 }} }"
|
|
||||||
@mousedown.prevent="query = '{{ park.name }}'; $refs.search.value = '{{ park.name }}'"
|
|
||||||
@mousedown.prevent="query = '{{ park.name }}'; $dispatch('search-selected', '{{ park.name }}'); open = false;"
|
|
||||||
role="option"
|
|
||||||
:aria-selected="focusedIndex === {{ forloop.counter0 }}"
|
|
||||||
tabindex="-1"
|
|
||||||
x-effect="if(focusedIndex === {{ forloop.counter0 }}) $el.scrollIntoView({block: 'nearest'})"
|
|
||||||
aria-label="{{ park.name }}{% if location.city %} in {{ location.city }}{% endif %}{% if location.state %}, {{ location.state }}{% endif %}">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium" x-text="focusedIndex === {{ forloop.counter0 }} ? '▶ {{ park.name }}' : '{{ park.name }}'"></span>
|
|
||||||
<span class="text-gray-500">
|
|
||||||
{% if location.city %}{{ location.city }}, {% endif %}
|
|
||||||
{% if location.state %}{{ location.state }}{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if query %}No parks found matching "{{ query }}"{% else %}Start typing to search parks{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -18,8 +18,6 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Areas and search endpoints for HTMX
|
# Areas and search endpoints for HTMX
|
||||||
path("areas/", views.get_park_areas, name="get_park_areas"),
|
path("areas/", views.get_park_areas, name="get_park_areas"),
|
||||||
path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"),
|
|
||||||
|
|
||||||
path("search/", views.search_parks, name="search_parks"),
|
path("search/", views.search_parks, name="search_parks"),
|
||||||
|
|
||||||
# Park detail and related views
|
# Park detail and related views
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import render
|
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@@ -13,6 +12,8 @@ class ParkSearchView(TemplateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Initialize search form
|
||||||
context['search_form'] = ParkSearchForm(self.request.GET)
|
context['search_form'] = ParkSearchForm(self.request.GET)
|
||||||
|
|
||||||
# Initialize filter with current querystring
|
# Initialize filter with current querystring
|
||||||
@@ -20,11 +21,7 @@ class ParkSearchView(TemplateView):
|
|||||||
filter_instance = ParkFilter(self.request.GET, queryset=queryset)
|
filter_instance = ParkFilter(self.request.GET, queryset=queryset)
|
||||||
context['filter'] = filter_instance
|
context['filter'] = filter_instance
|
||||||
|
|
||||||
# Apply search if park ID selected via autocomplete
|
# Get filtered queryset
|
||||||
park_id = self.request.GET.get('park')
|
|
||||||
if park_id:
|
|
||||||
queryset = filter_instance.qs.filter(id=park_id)
|
|
||||||
else:
|
|
||||||
queryset = filter_instance.qs
|
queryset = filter_instance.qs
|
||||||
|
|
||||||
# Handle view mode
|
# Handle view mode
|
||||||
@@ -32,23 +29,3 @@ class ParkSearchView(TemplateView):
|
|||||||
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,44 +1,37 @@
|
|||||||
/* Alert Styles */
|
|
||||||
.alert {
|
.alert {
|
||||||
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4;
|
padding: 1rem;
|
||||||
animation: slideIn 0.5s ease-out forwards;
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-success {
|
.alert-success {
|
||||||
@apply text-white bg-green-500;
|
background-color: #E8F5E9;
|
||||||
|
border: 1px solid #A5D6A7;
|
||||||
|
color: #2E7D32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-error {
|
.alert-error {
|
||||||
@apply text-white bg-red-500;
|
background-color: #FFEBEE;
|
||||||
}
|
border: 1px solid #FFCDD2;
|
||||||
|
color: #C62828;
|
||||||
.alert-info {
|
|
||||||
@apply text-white bg-blue-500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-warning {
|
.alert-warning {
|
||||||
@apply text-white bg-yellow-500;
|
background-color: #FFF3E0;
|
||||||
|
border: 1px solid #FFCC80;
|
||||||
|
color: #EF6C00;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animation keyframes */
|
.alert-info {
|
||||||
@keyframes slideIn {
|
background-color: #E3F2FD;
|
||||||
0% {
|
border: 1px solid #90CAF9;
|
||||||
transform: translateX(100%);
|
color: #1565C0;
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideOut {
|
.alert.fade-out {
|
||||||
0% {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Get all alert elements
|
|
||||||
const alerts = document.querySelectorAll('.alert');
|
const alerts = document.querySelectorAll('.alert');
|
||||||
|
|
||||||
// For each alert
|
|
||||||
alerts.forEach(alert => {
|
alerts.forEach(alert => {
|
||||||
// After 5 seconds
|
// Auto-hide alerts after 5 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Add slideOut animation
|
alert.classList.add('fade-out');
|
||||||
alert.style.animation = 'slideOut 0.5s ease-out forwards';
|
|
||||||
|
|
||||||
// Remove the alert after animation completes
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
alert.remove();
|
alert.remove();
|
||||||
}, 500);
|
}, 300); // Match CSS transition duration
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
// Add click-to-dismiss functionality
|
||||||
|
alert.addEventListener('click', () => {
|
||||||
|
alert.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
5
static/js/alpine.min.js
vendored
5
static/js/alpine.min.js
vendored
File diff suppressed because one or more lines are too long
5
static/js/cdn.min.js
vendored
5
static/js/cdn.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,81 +1,50 @@
|
|||||||
function locationAutocomplete(field, filterParks = false) {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
return {
|
const countryInput = document.querySelector('[name="country"]');
|
||||||
query: '',
|
const regionInput = document.querySelector('[name="region"]');
|
||||||
suggestions: [],
|
const cityInput = document.querySelector('[name="city"]');
|
||||||
fetchSuggestions() {
|
|
||||||
let url;
|
if (!countryInput || !regionInput || !cityInput) return;
|
||||||
const params = new URLSearchParams({
|
|
||||||
q: this.query,
|
// Update regions when country changes
|
||||||
filter_parks: filterParks
|
countryInput.addEventListener('change', () => {
|
||||||
|
const country = countryInput.value;
|
||||||
|
if (country) {
|
||||||
|
updateRegions(country);
|
||||||
|
// Clear city when country changes
|
||||||
|
cityInput.innerHTML = '<option value="">Select a city</option>';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
switch (field) {
|
// Update cities when region changes
|
||||||
case 'country':
|
regionInput.addEventListener('change', () => {
|
||||||
url = '/parks/ajax/countries/';
|
const country = countryInput.value;
|
||||||
break;
|
const region = regionInput.value;
|
||||||
case 'region':
|
if (country && region) {
|
||||||
url = '/parks/ajax/regions/';
|
updateCities(country, region);
|
||||||
// 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) {
|
function updateRegions(country) {
|
||||||
fetch(`${url}?${params}`)
|
fetch(`/location/regions/?country=${encodeURIComponent(country)}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
this.suggestions = data;
|
regionInput.innerHTML = '<option value="">Select a region</option>';
|
||||||
|
data.regions.forEach(region => {
|
||||||
|
const option = new Option(region, region);
|
||||||
|
regionInput.add(option);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
|
||||||
selectSuggestion(suggestion) {
|
|
||||||
this.query = suggestion.name;
|
|
||||||
this.suggestions = [];
|
|
||||||
|
|
||||||
// If this is a form field (not filter), update hidden fields
|
function updateCities(country, region) {
|
||||||
if (!filterParks) {
|
fetch(`/location/cities/?country=${encodeURIComponent(country)}®ion=${encodeURIComponent(region)}`)
|
||||||
const hiddenField = document.getElementById(`id_${field}`);
|
.then(response => response.json())
|
||||||
if (hiddenField) {
|
.then(data => {
|
||||||
hiddenField.value = suggestion.id;
|
cityInput.innerHTML = '<option value="">Select a city</option>';
|
||||||
}
|
data.cities.forEach(city => {
|
||||||
|
const option = new Option(city, city);
|
||||||
// Clear dependent fields when parent field changes
|
cityInput.add(option);
|
||||||
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,141 +1,40 @@
|
|||||||
// Theme handling
|
// Theme Toggle
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
const html = document.documentElement;
|
const themeIcon = themeToggle.nextElementSibling.querySelector('i');
|
||||||
|
|
||||||
// Initialize toggle state based on current theme
|
// Set initial icon
|
||||||
if (themeToggle) {
|
updateThemeIcon();
|
||||||
themeToggle.checked = html.classList.contains('dark');
|
|
||||||
|
|
||||||
// Handle toggle changes
|
themeToggle.addEventListener('change', () => {
|
||||||
themeToggle.addEventListener('change', function() {
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
const isDark = this.checked;
|
document.documentElement.classList.remove('dark');
|
||||||
html.classList.toggle('dark', isDark);
|
localStorage.setItem('theme', 'light');
|
||||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
} else {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
}
|
||||||
|
updateThemeIcon();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for system theme changes
|
function updateThemeIcon() {
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
mediaQuery.addEventListener('change', (e) => {
|
themeIcon.classList.remove('fa-sun', 'fa-moon');
|
||||||
if (!localStorage.getItem('theme')) {
|
themeIcon.classList.add(isDark ? 'fa-sun' : 'fa-moon');
|
||||||
const isDark = e.matches;
|
|
||||||
html.classList.toggle('dark', isDark);
|
|
||||||
themeToggle.checked = isDark;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle search form submission
|
// Mobile Menu Toggle
|
||||||
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');
|
||||||
|
|
||||||
if (mobileMenuBtn && mobileMenu) {
|
mobileMenu.style.display = 'none';
|
||||||
let isMenuOpen = false;
|
let isMenuOpen = false;
|
||||||
|
|
||||||
const toggleMenu = () => {
|
mobileMenuBtn.addEventListener('click', () => {
|
||||||
isMenuOpen = !isMenuOpen;
|
isMenuOpen = !isMenuOpen;
|
||||||
mobileMenu.classList.toggle('show', isMenuOpen);
|
mobileMenu.style.display = isMenuOpen ? 'block' : 'none';
|
||||||
mobileMenuBtn.setAttribute('aria-expanded', isMenuOpen.toString());
|
menuIcon.classList.remove('fa-bars', 'fa-times');
|
||||||
|
menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
|
||||||
// Update icon
|
|
||||||
const icon = mobileMenuBtn.querySelector('i');
|
|
||||||
icon.classList.remove(isMenuOpen ? 'fa-bars' : 'fa-times');
|
|
||||||
icon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
|
|
||||||
};
|
|
||||||
|
|
||||||
mobileMenuBtn.addEventListener('click', toggleMenu);
|
|
||||||
|
|
||||||
// Close menu when clicking outside
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (isMenuOpen && !mobileMenu.contains(e.target) && !mobileMenuBtn.contains(e.target)) {
|
|
||||||
toggleMenu();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close menu when pressing escape
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (isMenuOpen && e.key === 'Escape') {
|
|
||||||
toggleMenu();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle viewport changes
|
|
||||||
const mediaQuery = window.matchMedia('(min-width: 1024px)');
|
|
||||||
mediaQuery.addEventListener('change', (e) => {
|
|
||||||
if (e.matches && isMenuOpen) {
|
|
||||||
toggleMenu();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// User dropdown toggle
|
|
||||||
const userMenuBtn = document.getElementById('userMenuBtn');
|
|
||||||
const userDropdown = document.getElementById('userDropdown');
|
|
||||||
|
|
||||||
if (userMenuBtn && userDropdown) {
|
|
||||||
userMenuBtn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
userDropdown.classList.toggle('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!userMenuBtn.contains(e.target) && !userDropdown.contains(e.target)) {
|
|
||||||
userDropdown.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdown when pressing escape
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
userDropdown.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle flash messages
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const alerts = document.querySelectorAll('.alert');
|
|
||||||
alerts.forEach(alert => {
|
|
||||||
setTimeout(() => {
|
|
||||||
alert.style.opacity = '0';
|
|
||||||
setTimeout(() => alert.remove(), 300);
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize tooltips
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const tooltips = document.querySelectorAll('[data-tooltip]');
|
|
||||||
tooltips.forEach(tooltip => {
|
|
||||||
tooltip.addEventListener('mouseenter', (e) => {
|
|
||||||
const text = e.target.getAttribute('data-tooltip');
|
|
||||||
const tooltipEl = document.createElement('div');
|
|
||||||
tooltipEl.className = 'absolute z-50 px-2 py-1 text-sm text-white bg-gray-900 rounded tooltip';
|
|
||||||
tooltipEl.textContent = text;
|
|
||||||
document.body.appendChild(tooltipEl);
|
|
||||||
|
|
||||||
const rect = e.target.getBoundingClientRect();
|
|
||||||
tooltipEl.style.top = rect.bottom + 5 + 'px';
|
|
||||||
tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px';
|
|
||||||
});
|
|
||||||
|
|
||||||
tooltip.addEventListener('mouseleave', () => {
|
|
||||||
const tooltips = document.querySelectorAll('.tooltip');
|
|
||||||
tooltips.forEach(t => t.remove());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// Only declare parkMap if it doesn't exist
|
|
||||||
window.parkMap = window.parkMap || null;
|
|
||||||
|
|
||||||
function initParkMap(latitude, longitude, name) {
|
|
||||||
const mapContainer = document.getElementById('park-map');
|
|
||||||
|
|
||||||
// Only initialize if container exists and map hasn't been initialized
|
|
||||||
if (mapContainer && !window.parkMap) {
|
|
||||||
const width = mapContainer.offsetWidth;
|
|
||||||
mapContainer.style.height = width + 'px';
|
|
||||||
|
|
||||||
window.parkMap = L.map('park-map').setView([latitude, longitude], 13);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors'
|
|
||||||
}).addTo(window.parkMap);
|
|
||||||
|
|
||||||
L.marker([latitude, longitude])
|
|
||||||
.addTo(window.parkMap)
|
|
||||||
.bindPopup(name);
|
|
||||||
|
|
||||||
// Update map size when window is resized
|
|
||||||
window.addEventListener('resize', function() {
|
|
||||||
const width = mapContainer.offsetWidth;
|
|
||||||
mapContainer.style.height = width + 'px';
|
|
||||||
window.parkMap.invalidateSize();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
document.addEventListener('alpine:init', () => {
|
|
||||||
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
|
|
||||||
photos,
|
|
||||||
fullscreenPhoto: null,
|
|
||||||
uploading: false,
|
|
||||||
uploadProgress: 0,
|
|
||||||
error: null,
|
|
||||||
showSuccess: false,
|
|
||||||
|
|
||||||
showFullscreen(photo) {
|
|
||||||
this.fullscreenPhoto = photo;
|
|
||||||
},
|
|
||||||
|
|
||||||
async handleFileSelect(event) {
|
|
||||||
const files = Array.from(event.target.files);
|
|
||||||
if (!files.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uploading = true;
|
|
||||||
this.uploadProgress = 0;
|
|
||||||
this.error = null;
|
|
||||||
this.showSuccess = false;
|
|
||||||
|
|
||||||
const totalFiles = files.length;
|
|
||||||
let completedFiles = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image', file);
|
|
||||||
formData.append('app_label', contentType.split('.')[0]);
|
|
||||||
formData.append('model', contentType.split('.')[1]);
|
|
||||||
formData.append('object_id', objectId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(uploadUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRFToken': csrfToken,
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
throw new Error(data.error || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const photo = await response.json();
|
|
||||||
this.photos.push(photo);
|
|
||||||
completedFiles++;
|
|
||||||
this.uploadProgress = (completedFiles / totalFiles) * 100;
|
|
||||||
} catch (err) {
|
|
||||||
this.error = err.message || 'Failed to upload photo. Please try again.';
|
|
||||||
console.error('Upload error:', err);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uploading = false;
|
|
||||||
event.target.value = ''; // Reset file input
|
|
||||||
|
|
||||||
if (!this.error) {
|
|
||||||
this.showSuccess = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
this.showSuccess = false;
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async sharePhoto(photo) {
|
|
||||||
if (navigator.share) {
|
|
||||||
try {
|
|
||||||
await navigator.share({
|
|
||||||
title: photo.caption || 'Shared photo',
|
|
||||||
url: photo.url
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err.name !== 'AbortError') {
|
|
||||||
console.error('Error sharing:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: copy URL to clipboard
|
|
||||||
navigator.clipboard.writeText(photo.url)
|
|
||||||
.then(() => alert('Photo URL copied to clipboard!'))
|
|
||||||
.catch(err => console.error('Error copying to clipboard:', err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
function parkSearch() {
|
|
||||||
return {
|
|
||||||
query: '',
|
|
||||||
results: [],
|
|
||||||
loading: false,
|
|
||||||
selectedId: null,
|
|
||||||
|
|
||||||
async search() {
|
|
||||||
if (!this.query.trim()) {
|
|
||||||
this.results = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/parks/suggest_parks/?search=${encodeURIComponent(this.query)}`);
|
|
||||||
const data = await response.json();
|
|
||||||
this.results = data.results;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search failed:', error);
|
|
||||||
this.results = [];
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.query = '';
|
|
||||||
this.results = [];
|
|
||||||
this.selectedId = null;
|
|
||||||
},
|
|
||||||
|
|
||||||
selectPark(park) {
|
|
||||||
this.query = park.name;
|
|
||||||
this.selectedId = park.id;
|
|
||||||
this.results = [];
|
|
||||||
|
|
||||||
// Trigger filter update
|
|
||||||
document.getElementById('park-filters').dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,37 @@
|
|||||||
/* Alert Styles */
|
|
||||||
.alert {
|
.alert {
|
||||||
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4;
|
padding: 1rem;
|
||||||
animation: slideIn 0.5s ease-out forwards;
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-success {
|
.alert-success {
|
||||||
@apply text-white bg-green-500;
|
background-color: #E8F5E9;
|
||||||
|
border: 1px solid #A5D6A7;
|
||||||
|
color: #2E7D32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-error {
|
.alert-error {
|
||||||
@apply text-white bg-red-500;
|
background-color: #FFEBEE;
|
||||||
}
|
border: 1px solid #FFCDD2;
|
||||||
|
color: #C62828;
|
||||||
.alert-info {
|
|
||||||
@apply text-white bg-blue-500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-warning {
|
.alert-warning {
|
||||||
@apply text-white bg-yellow-500;
|
background-color: #FFF3E0;
|
||||||
|
border: 1px solid #FFCC80;
|
||||||
|
color: #EF6C00;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animation keyframes */
|
.alert-info {
|
||||||
@keyframes slideIn {
|
background-color: #E3F2FD;
|
||||||
0% {
|
border: 1px solid #90CAF9;
|
||||||
transform: translateX(100%);
|
color: #1565C0;
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideOut {
|
.alert.fade-out {
|
||||||
0% {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2181,6 +2181,18 @@ select {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.visible {
|
.visible {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
@@ -2457,6 +2469,10 @@ select {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-10 {
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.h-16 {
|
.h-16 {
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
}
|
}
|
||||||
@@ -2485,6 +2501,10 @@ select {
|
|||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-6 {
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.h-8 {
|
.h-8 {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
}
|
}
|
||||||
@@ -2533,6 +2553,10 @@ select {
|
|||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-6 {
|
||||||
|
width: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.w-64 {
|
.w-64 {
|
||||||
width: 16rem;
|
width: 16rem;
|
||||||
}
|
}
|
||||||
@@ -2646,6 +2670,16 @@ select {
|
|||||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
@@ -3000,10 +3034,6 @@ select {
|
|||||||
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
|
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-gray-900\/80 {
|
|
||||||
background-color: rgb(17 24 39 / 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-green-100 {
|
.bg-green-100 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
|
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
|
||||||
@@ -3244,6 +3274,10 @@ select {
|
|||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pt-2 {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.text-left {
|
.text-left {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -3335,6 +3369,11 @@ select {
|
|||||||
color: rgb(37 99 235 / var(--tw-text-opacity));
|
color: rgb(37 99 235 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-blue-700 {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(29 78 216 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.text-blue-800 {
|
.text-blue-800 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
color: rgb(30 64 175 / var(--tw-text-opacity));
|
||||||
@@ -3405,6 +3444,11 @@ select {
|
|||||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-red-100 {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(254 226 226 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.text-red-400 {
|
.text-red-400 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||||
@@ -3507,6 +3551,11 @@ select {
|
|||||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outline-none {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.ring-2 {
|
.ring-2 {
|
||||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
@@ -3522,6 +3571,19 @@ select {
|
|||||||
--tw-ring-color: rgb(79 70 229 / 0.2);
|
--tw-ring-color: rgb(79 70 229 / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ring-offset-2 {
|
||||||
|
--tw-ring-offset-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-offset-white {
|
||||||
|
--tw-ring-offset-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blur {
|
||||||
|
--tw-blur: blur(8px);
|
||||||
|
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
.filter {
|
.filter {
|
||||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||||
}
|
}
|
||||||
@@ -3796,6 +3858,11 @@ select {
|
|||||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.focus\:bg-gray-100:focus {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.focus\:underline:focus {
|
.focus\:underline:focus {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
}
|
}
|
||||||
@@ -3824,6 +3891,10 @@ select {
|
|||||||
--tw-ring-offset-width: 2px;
|
--tw-ring-offset-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active\:transform:active {
|
||||||
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||||
|
}
|
||||||
|
|
||||||
.disabled\:opacity-50:disabled {
|
.disabled\:opacity-50:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
@@ -3930,6 +4001,10 @@ select {
|
|||||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:bg-red-900\/40:is(.dark *) {
|
||||||
|
background-color: rgb(127 29 29 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:bg-yellow-200:is(.dark *) {
|
.dark\:bg-yellow-200:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
|
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
|
||||||
@@ -3968,6 +4043,11 @@ select {
|
|||||||
--tw-gradient-to: #3b0764 var(--tw-gradient-to-position);
|
--tw-gradient-to: #3b0764 var(--tw-gradient-to-position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:text-blue-100:is(.dark *) {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(219 234 254 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:text-blue-200:is(.dark *) {
|
.dark\:text-blue-200:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(191 219 254 / var(--tw-text-opacity));
|
color: rgb(191 219 254 / var(--tw-text-opacity));
|
||||||
@@ -4190,6 +4270,11 @@ select {
|
|||||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:focus\:bg-gray-700:focus:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.sm\:col-span-3 {
|
.sm\:col-span-3 {
|
||||||
grid-column: span 3 / span 3;
|
grid-column: span 3 / span 3;
|
||||||
@@ -4297,10 +4382,26 @@ select {
|
|||||||
grid-column: span 2 / span 2;
|
grid-column: span 2 / span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md\:col-span-3 {
|
||||||
|
grid-column: span 3 / span 3;
|
||||||
|
}
|
||||||
|
|
||||||
.md\:mb-8 {
|
.md\:mb-8 {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md\:block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.md\:h-\[140px\] {
|
.md\:h-\[140px\] {
|
||||||
height: 140px;
|
height: 140px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Get all alert elements
|
|
||||||
const alerts = document.querySelectorAll('.alert');
|
const alerts = document.querySelectorAll('.alert');
|
||||||
|
|
||||||
// For each alert
|
|
||||||
alerts.forEach(alert => {
|
alerts.forEach(alert => {
|
||||||
// After 5 seconds
|
// Auto-hide alerts after 5 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Add slideOut animation
|
alert.classList.add('fade-out');
|
||||||
alert.style.animation = 'slideOut 0.5s ease-out forwards';
|
|
||||||
|
|
||||||
// Remove the alert after animation completes
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
alert.remove();
|
alert.remove();
|
||||||
}, 500);
|
}, 300); // Match CSS transition duration
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
// Add click-to-dismiss functionality
|
||||||
|
alert.addEventListener('click', () => {
|
||||||
|
alert.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
5
staticfiles/js/alpine.min.js
vendored
5
staticfiles/js/alpine.min.js
vendored
File diff suppressed because one or more lines are too long
5
staticfiles/js/cdn.min.js
vendored
5
staticfiles/js/cdn.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,262 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Handle edit button clicks
|
|
||||||
document.querySelectorAll('[data-edit-button]').forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const contentId = this.dataset.contentId;
|
|
||||||
const contentType = this.dataset.contentType;
|
|
||||||
const editableFields = document.querySelectorAll(`[data-editable][data-content-id="${contentId}"]`);
|
|
||||||
|
|
||||||
// Toggle edit mode
|
|
||||||
editableFields.forEach(field => {
|
|
||||||
const currentValue = field.textContent.trim();
|
|
||||||
const fieldName = field.dataset.fieldName;
|
|
||||||
const fieldType = field.dataset.fieldType || 'text';
|
|
||||||
|
|
||||||
// Create input field
|
|
||||||
let input;
|
|
||||||
if (fieldType === 'textarea') {
|
|
||||||
input = document.createElement('textarea');
|
|
||||||
input.value = currentValue;
|
|
||||||
input.rows = 4;
|
|
||||||
} else if (fieldType === 'select') {
|
|
||||||
input = document.createElement('select');
|
|
||||||
// Get options from data attribute
|
|
||||||
const options = JSON.parse(field.dataset.options || '[]');
|
|
||||||
options.forEach(option => {
|
|
||||||
const optionEl = document.createElement('option');
|
|
||||||
optionEl.value = option.value;
|
|
||||||
optionEl.textContent = option.label;
|
|
||||||
optionEl.selected = option.value === currentValue;
|
|
||||||
input.appendChild(optionEl);
|
|
||||||
});
|
|
||||||
} else if (fieldType === 'date') {
|
|
||||||
input = document.createElement('input');
|
|
||||||
input.type = 'date';
|
|
||||||
input.value = currentValue;
|
|
||||||
} else if (fieldType === 'number') {
|
|
||||||
input = document.createElement('input');
|
|
||||||
input.type = 'number';
|
|
||||||
input.value = currentValue;
|
|
||||||
if (field.dataset.min) input.min = field.dataset.min;
|
|
||||||
if (field.dataset.max) input.max = field.dataset.max;
|
|
||||||
if (field.dataset.step) input.step = field.dataset.step;
|
|
||||||
} else {
|
|
||||||
input = document.createElement('input');
|
|
||||||
input.type = fieldType;
|
|
||||||
input.value = currentValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.className = 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white';
|
|
||||||
input.dataset.originalValue = currentValue;
|
|
||||||
input.dataset.fieldName = fieldName;
|
|
||||||
|
|
||||||
// Replace content with input
|
|
||||||
field.textContent = '';
|
|
||||||
field.appendChild(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show save/cancel buttons
|
|
||||||
const actionButtons = document.createElement('div');
|
|
||||||
actionButtons.className = 'flex gap-2 mt-2';
|
|
||||||
actionButtons.innerHTML = `
|
|
||||||
<button class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600" data-save-button>
|
|
||||||
<i class="mr-2 fas fa-save"></i>Save Changes
|
|
||||||
</button>
|
|
||||||
<button class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500" data-cancel-button>
|
|
||||||
<i class="mr-2 fas fa-times"></i>Cancel
|
|
||||||
</button>
|
|
||||||
${this.dataset.requireReason ? `
|
|
||||||
<div class="flex-grow">
|
|
||||||
<input type="text" class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="Reason for changes (required)"
|
|
||||||
data-reason-input>
|
|
||||||
<input type="text" class="w-full mt-1 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="Source (optional)"
|
|
||||||
data-source-input>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const container = editableFields[0].closest('.editable-container');
|
|
||||||
container.appendChild(actionButtons);
|
|
||||||
|
|
||||||
// Hide edit button while editing
|
|
||||||
this.style.display = 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle form submissions
|
|
||||||
document.querySelectorAll('form[data-submit-type]').forEach(form => {
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const submitType = this.dataset.submitType;
|
|
||||||
const formData = new FormData(this);
|
|
||||||
const data = {};
|
|
||||||
|
|
||||||
formData.forEach((value, key) => {
|
|
||||||
data[key] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get CSRF token from meta tag
|
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
fetch(this.action, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
submission_type: submitType,
|
|
||||||
...data
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'success') {
|
|
||||||
showNotification(data.message, 'success');
|
|
||||||
if (data.redirect_url) {
|
|
||||||
window.location.href = data.redirect_url;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showNotification(data.message, 'error');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
showNotification('An error occurred while submitting the form.', 'error');
|
|
||||||
console.error('Error:', error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle save button clicks using event delegation
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.matches('[data-save-button]')) {
|
|
||||||
const container = e.target.closest('.editable-container');
|
|
||||||
const contentId = container.querySelector('[data-editable]').dataset.contentId;
|
|
||||||
const contentType = container.querySelector('[data-edit-button]').dataset.contentType;
|
|
||||||
const editableFields = container.querySelectorAll('[data-editable]');
|
|
||||||
|
|
||||||
// Collect changes
|
|
||||||
const changes = {};
|
|
||||||
editableFields.forEach(field => {
|
|
||||||
const input = field.querySelector('input, textarea, select');
|
|
||||||
if (input && input.value !== input.dataset.originalValue) {
|
|
||||||
changes[input.dataset.fieldName] = input.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no changes, just cancel
|
|
||||||
if (Object.keys(changes).length === 0) {
|
|
||||||
cancelEdit(container);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get reason and source if required
|
|
||||||
const reasonInput = container.querySelector('[data-reason-input]');
|
|
||||||
const sourceInput = container.querySelector('[data-source-input]');
|
|
||||||
const reason = reasonInput ? reasonInput.value : '';
|
|
||||||
const source = sourceInput ? sourceInput.value : '';
|
|
||||||
|
|
||||||
// Validate reason if required
|
|
||||||
if (reasonInput && !reason) {
|
|
||||||
alert('Please provide a reason for your changes.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get CSRF token from meta tag
|
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
|
||||||
|
|
||||||
// Submit changes
|
|
||||||
fetch(window.location.pathname, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
content_type: contentType,
|
|
||||||
content_id: contentId,
|
|
||||||
changes,
|
|
||||||
reason,
|
|
||||||
source
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'success') {
|
|
||||||
if (data.auto_approved) {
|
|
||||||
// Update the display immediately
|
|
||||||
Object.entries(changes).forEach(([field, value]) => {
|
|
||||||
const element = container.querySelector(`[data-editable][data-field-name="${field}"]`);
|
|
||||||
if (element) {
|
|
||||||
element.textContent = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
showNotification(data.message, 'success');
|
|
||||||
if (data.redirect_url) {
|
|
||||||
window.location.href = data.redirect_url;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showNotification(data.message, 'error');
|
|
||||||
}
|
|
||||||
cancelEdit(container);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
showNotification('An error occurred while saving changes.', 'error');
|
|
||||||
console.error('Error:', error);
|
|
||||||
cancelEdit(container);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle cancel button clicks using event delegation
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.matches('[data-cancel-button]')) {
|
|
||||||
const container = e.target.closest('.editable-container');
|
|
||||||
cancelEdit(container);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function cancelEdit(container) {
|
|
||||||
// Restore original content
|
|
||||||
container.querySelectorAll('[data-editable]').forEach(field => {
|
|
||||||
const input = field.querySelector('input, textarea, select');
|
|
||||||
if (input) {
|
|
||||||
field.textContent = input.dataset.originalValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove action buttons
|
|
||||||
const actionButtons = container.querySelector('.flex.gap-2');
|
|
||||||
if (actionButtons) {
|
|
||||||
actionButtons.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show edit button
|
|
||||||
const editButton = container.querySelector('[data-edit-button]');
|
|
||||||
if (editButton) {
|
|
||||||
editButton.style.display = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNotification(message, type = 'info') {
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = `fixed bottom-4 right-4 p-4 rounded-lg shadow-lg text-white ${
|
|
||||||
type === 'success' ? 'bg-green-600 dark:bg-green-500' :
|
|
||||||
type === 'error' ? 'bg-red-600 dark:bg-red-500' :
|
|
||||||
'bg-blue-600 dark:bg-blue-500'
|
|
||||||
}`;
|
|
||||||
notification.textContent = message;
|
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
|
|
||||||
// Remove after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.remove();
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
function locationAutocomplete(field, filterParks = false) {
|
|
||||||
return {
|
|
||||||
query: '',
|
|
||||||
suggestions: [],
|
|
||||||
fetchSuggestions() {
|
|
||||||
let url;
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
q: this.query,
|
|
||||||
filter_parks: filterParks
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (field) {
|
|
||||||
case 'country':
|
|
||||||
url = '/parks/ajax/countries/';
|
|
||||||
break;
|
|
||||||
case 'region':
|
|
||||||
url = '/parks/ajax/regions/';
|
|
||||||
// Add country parameter if we're fetching regions
|
|
||||||
const countryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
|
|
||||||
if (countryInput && countryInput.value) {
|
|
||||||
params.append('country', countryInput.value);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'city':
|
|
||||||
url = '/parks/ajax/cities/';
|
|
||||||
// Add country and region parameters if we're fetching cities
|
|
||||||
const regionInput = document.getElementById(filterParks ? 'region' : 'id_region_name');
|
|
||||||
const cityCountryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
|
|
||||||
if (regionInput && regionInput.value && cityCountryInput && cityCountryInput.value) {
|
|
||||||
params.append('country', cityCountryInput.value);
|
|
||||||
params.append('region', regionInput.value);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
fetch(`${url}?${params}`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
this.suggestions = data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectSuggestion(suggestion) {
|
|
||||||
this.query = suggestion.name;
|
|
||||||
this.suggestions = [];
|
|
||||||
|
|
||||||
// If this is a form field (not filter), update hidden fields
|
|
||||||
if (!filterParks) {
|
|
||||||
const hiddenField = document.getElementById(`id_${field}`);
|
|
||||||
if (hiddenField) {
|
|
||||||
hiddenField.value = suggestion.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear dependent fields when parent field changes
|
|
||||||
if (field === 'country') {
|
|
||||||
const regionInput = document.getElementById('id_region_name');
|
|
||||||
const cityInput = document.getElementById('id_city_name');
|
|
||||||
const regionHidden = document.getElementById('id_region');
|
|
||||||
const cityHidden = document.getElementById('id_city');
|
|
||||||
|
|
||||||
if (regionInput) regionInput.value = '';
|
|
||||||
if (cityInput) cityInput.value = '';
|
|
||||||
if (regionHidden) regionHidden.value = '';
|
|
||||||
if (cityHidden) cityHidden.value = '';
|
|
||||||
} else if (field === 'region') {
|
|
||||||
const cityInput = document.getElementById('id_city_name');
|
|
||||||
const cityHidden = document.getElementById('id_city');
|
|
||||||
|
|
||||||
if (cityInput) cityInput.value = '';
|
|
||||||
if (cityHidden) cityHidden.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger form submission for filters
|
|
||||||
if (filterParks) {
|
|
||||||
htmx.trigger('#park-filters', 'change');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,79 +1,40 @@
|
|||||||
// Initialize dark mode from localStorage
|
// Theme Toggle
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Check if dark mode was previously enabled
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
const darkMode = localStorage.getItem('darkMode') === 'true';
|
const themeIcon = themeToggle.nextElementSibling.querySelector('i');
|
||||||
if (darkMode) {
|
|
||||||
|
// Set initial icon
|
||||||
|
updateThemeIcon();
|
||||||
|
|
||||||
|
themeToggle.addEventListener('change', () => {
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
} else {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
}
|
}
|
||||||
|
updateThemeIcon();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle search form submission
|
function updateThemeIcon() {
|
||||||
document.addEventListener('submit', (e) => {
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
if (e.target.matches('form[action*="search"]')) {
|
themeIcon.classList.remove('fa-sun', 'fa-moon');
|
||||||
const searchInput = e.target.querySelector('input[name="q"]');
|
themeIcon.classList.add(isDark ? 'fa-sun' : 'fa-moon');
|
||||||
if (!searchInput.value.trim()) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close mobile menu when clicking outside
|
// Mobile Menu Toggle
|
||||||
document.addEventListener('click', (e) => {
|
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
|
||||||
const mobileMenu = document.querySelector('[x-show="mobileMenuOpen"]');
|
const mobileMenu = document.getElementById('mobileMenu');
|
||||||
const menuButton = document.querySelector('[x-on\\:click="mobileMenuOpen = !mobileMenuOpen"]');
|
const menuIcon = mobileMenuBtn.querySelector('i');
|
||||||
|
|
||||||
if (mobileMenu && menuButton && !mobileMenu.contains(e.target) && !menuButton.contains(e.target)) {
|
mobileMenu.style.display = 'none';
|
||||||
const alpineData = mobileMenu._x_dataStack && mobileMenu._x_dataStack[0];
|
let isMenuOpen = false;
|
||||||
if (alpineData && alpineData.mobileMenuOpen) {
|
|
||||||
alpineData.mobileMenuOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle flash messages
|
mobileMenuBtn.addEventListener('click', () => {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
isMenuOpen = !isMenuOpen;
|
||||||
const alerts = document.querySelectorAll('.alert');
|
mobileMenu.style.display = isMenuOpen ? 'block' : 'none';
|
||||||
alerts.forEach(alert => {
|
menuIcon.classList.remove('fa-bars', 'fa-times');
|
||||||
setTimeout(() => {
|
menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
|
||||||
alert.style.opacity = '0';
|
|
||||||
setTimeout(() => alert.remove(), 300);
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize tooltips
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const tooltips = document.querySelectorAll('[data-tooltip]');
|
|
||||||
tooltips.forEach(tooltip => {
|
|
||||||
tooltip.addEventListener('mouseenter', (e) => {
|
|
||||||
const text = e.target.getAttribute('data-tooltip');
|
|
||||||
const tooltipEl = document.createElement('div');
|
|
||||||
tooltipEl.className = 'tooltip bg-gray-900 text-white px-2 py-1 rounded text-sm absolute z-50';
|
|
||||||
tooltipEl.textContent = text;
|
|
||||||
document.body.appendChild(tooltipEl);
|
|
||||||
|
|
||||||
const rect = e.target.getBoundingClientRect();
|
|
||||||
tooltipEl.style.top = rect.bottom + 5 + 'px';
|
|
||||||
tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px';
|
|
||||||
});
|
|
||||||
|
|
||||||
tooltip.addEventListener('mouseleave', () => {
|
|
||||||
const tooltips = document.querySelectorAll('.tooltip');
|
|
||||||
tooltips.forEach(t => t.remove());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle dropdown menus
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
const dropdowns = document.querySelectorAll('[x-show]');
|
|
||||||
dropdowns.forEach(dropdown => {
|
|
||||||
if (!dropdown.contains(e.target) &&
|
|
||||||
!e.target.matches('[x-on\\:click*="open = !open"]')) {
|
|
||||||
const alpineData = dropdown._x_dataStack && dropdown._x_dataStack[0];
|
|
||||||
if (alpineData && alpineData.open) {
|
|
||||||
alpineData.open = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
let parkMap = null;
|
|
||||||
|
|
||||||
function initParkMap(latitude, longitude, name) {
|
|
||||||
const mapContainer = document.getElementById('park-map');
|
|
||||||
|
|
||||||
// Only initialize if container exists and map hasn't been initialized
|
|
||||||
if (mapContainer && !parkMap) {
|
|
||||||
const width = mapContainer.offsetWidth;
|
|
||||||
mapContainer.style.height = width + 'px';
|
|
||||||
|
|
||||||
parkMap = L.map('park-map').setView([latitude, longitude], 13);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors'
|
|
||||||
}).addTo(parkMap);
|
|
||||||
|
|
||||||
L.marker([latitude, longitude])
|
|
||||||
.addTo(parkMap)
|
|
||||||
.bindPopup(name);
|
|
||||||
|
|
||||||
// Update map size when window is resized
|
|
||||||
window.addEventListener('resize', function() {
|
|
||||||
const width = mapContainer.offsetWidth;
|
|
||||||
mapContainer.style.height = width + 'px';
|
|
||||||
parkMap.invalidateSize();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
document.addEventListener('alpine:init', () => {
|
|
||||||
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
|
|
||||||
photos,
|
|
||||||
fullscreenPhoto: null,
|
|
||||||
uploading: false,
|
|
||||||
uploadProgress: 0,
|
|
||||||
error: null,
|
|
||||||
showSuccess: false,
|
|
||||||
|
|
||||||
showFullscreen(photo) {
|
|
||||||
this.fullscreenPhoto = photo;
|
|
||||||
},
|
|
||||||
|
|
||||||
async handleFileSelect(event) {
|
|
||||||
const files = Array.from(event.target.files);
|
|
||||||
if (!files.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uploading = true;
|
|
||||||
this.uploadProgress = 0;
|
|
||||||
this.error = null;
|
|
||||||
this.showSuccess = false;
|
|
||||||
|
|
||||||
const totalFiles = files.length;
|
|
||||||
let completedFiles = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image', file);
|
|
||||||
formData.append('app_label', contentType.split('.')[0]);
|
|
||||||
formData.append('model', contentType.split('.')[1]);
|
|
||||||
formData.append('object_id', objectId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(uploadUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRFToken': csrfToken,
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
throw new Error(data.error || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const photo = await response.json();
|
|
||||||
this.photos.push(photo);
|
|
||||||
completedFiles++;
|
|
||||||
this.uploadProgress = (completedFiles / totalFiles) * 100;
|
|
||||||
} catch (err) {
|
|
||||||
this.error = err.message || 'Failed to upload photo. Please try again.';
|
|
||||||
console.error('Upload error:', err);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uploading = false;
|
|
||||||
event.target.value = ''; // Reset file input
|
|
||||||
|
|
||||||
if (!this.error) {
|
|
||||||
this.showSuccess = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
this.showSuccess = false;
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async sharePhoto(photo) {
|
|
||||||
if (navigator.share) {
|
|
||||||
try {
|
|
||||||
await navigator.share({
|
|
||||||
title: photo.caption || 'Shared photo',
|
|
||||||
url: photo.url
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err.name !== 'AbortError') {
|
|
||||||
console.error('Error sharing:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: copy URL to clipboard
|
|
||||||
navigator.clipboard.writeText(photo.url)
|
|
||||||
.then(() => alert('Photo URL copied to clipboard!'))
|
|
||||||
.catch(err => console.error('Error copying to clipboard:', err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
134
staticfiles/parks/css/search.css
Normal file
134
staticfiles/parks/css/search.css
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/* Loading states */
|
||||||
|
.htmx-request .htmx-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.htmx-request.htmx-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.htmx-indicator {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results container transitions */
|
||||||
|
#park-results {
|
||||||
|
transition: opacity 200ms ease-in-out;
|
||||||
|
}
|
||||||
|
.htmx-request #park-results {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.htmx-settling #park-results {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid/List transitions */
|
||||||
|
.park-card {
|
||||||
|
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid view styles */
|
||||||
|
.park-card[data-view-mode="grid"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.park-card[data-view-mode="grid"]:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List view styles */
|
||||||
|
.park-card[data-view-mode="list"] {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.park-card[data-view-mode="list"]:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image containers */
|
||||||
|
.park-card .image-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.park-card[data-view-mode="grid"] .image-container {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.park-card[data-view-mode="list"] .image-container {
|
||||||
|
width: 6rem;
|
||||||
|
height: 6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.park-card .content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* Enables text truncation in flex child */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.park-card .status-badge {
|
||||||
|
transition: all 150ms ease-in-out;
|
||||||
|
}
|
||||||
|
.park-card:hover .status-badge {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Images */
|
||||||
|
.park-card img {
|
||||||
|
transition: transform 200ms ease-in-out;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.park-card:hover img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholders for missing images */
|
||||||
|
.park-card .placeholder {
|
||||||
|
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
to {
|
||||||
|
background-position: 200% center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.park-card {
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.park-card[data-view-mode="list"]:hover {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.park-card .text-gray-900 {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.park-card .text-gray-500 {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.park-card .placeholder {
|
||||||
|
background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.park-card[data-view-mode="list"]:hover {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
staticfiles/parks/js/search.js
Normal file
69
staticfiles/parks/js/search.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Handle view mode persistence across HTMX requests
|
||||||
|
document.addEventListener('htmx:configRequest', function(evt) {
|
||||||
|
// Preserve view mode
|
||||||
|
const parkResults = document.getElementById('park-results');
|
||||||
|
if (parkResults) {
|
||||||
|
const viewMode = parkResults.getAttribute('data-view-mode');
|
||||||
|
if (viewMode) {
|
||||||
|
evt.detail.parameters['view_mode'] = viewMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve search terms
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
if (searchInput && searchInput.value) {
|
||||||
|
evt.detail.parameters['search'] = searchInput.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle loading states
|
||||||
|
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||||
|
const target = evt.detail.target;
|
||||||
|
if (target) {
|
||||||
|
target.classList.add('htmx-requesting');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||||
|
const target = evt.detail.target;
|
||||||
|
if (target) {
|
||||||
|
target.classList.remove('htmx-requesting');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle history navigation
|
||||||
|
document.addEventListener('htmx:historyRestore', function(evt) {
|
||||||
|
const parkResults = document.getElementById('park-results');
|
||||||
|
if (parkResults && evt.detail.path) {
|
||||||
|
const url = new URL(evt.detail.path, window.location.origin);
|
||||||
|
const viewMode = url.searchParams.get('view_mode');
|
||||||
|
if (viewMode) {
|
||||||
|
parkResults.setAttribute('data-view-mode', viewMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize lazy loading for images
|
||||||
|
function initializeLazyLoading(container) {
|
||||||
|
if (!('IntersectionObserver' in window)) return;
|
||||||
|
|
||||||
|
const imageObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const img = entry.target;
|
||||||
|
img.src = img.dataset.src;
|
||||||
|
img.removeAttribute('data-src');
|
||||||
|
imageObserver.unobserve(img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('img[data-src]').forEach(img => {
|
||||||
|
imageObserver.observe(img);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize lazy loading after HTMX content swaps
|
||||||
|
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
|
initializeLazyLoading(evt.detail.target);
|
||||||
|
});
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
||||||
|
|
||||||
<!-- Alpine.js -->
|
<!-- Alpine.js -->
|
||||||
<script defer src="{% static 'js/alpine.min.js' %}"></script>
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
<!-- Location Autocomplete -->
|
<!-- Location Autocomplete -->
|
||||||
<script src="{% static 'js/location-autocomplete.js' %}"></script>
|
<script src="{% static 'js/location-autocomplete.js' %}"></script>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^"
|
SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^"
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = False
|
DEBUG = True
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = ["https://beta.thrillwiki.com"]
|
CSRF_TRUSTED_ORIGINS = ["https://beta.thrillwiki.com"]
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from accounts import views as accounts_views
|
|||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from .views import HomeView, SearchView
|
from .views import HomeView, SearchView
|
||||||
from . import views
|
from . import views
|
||||||
|
from autocomplete.urls import urlpatterns as autocomplete_patterns
|
||||||
import os
|
import os
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -58,6 +59,8 @@ urlpatterns = [
|
|||||||
views***REMOVED***ironment_and_settings_view,
|
views***REMOVED***ironment_and_settings_view,
|
||||||
name="environment_and_settings",
|
name="environment_and_settings",
|
||||||
),
|
),
|
||||||
|
# Autocomplete URLs
|
||||||
|
path("ac/", include((autocomplete_patterns, "autocomplete"), namespace="autocomplete")),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Serve static files in development
|
# Serve static files in development
|
||||||
|
|||||||
Reference in New Issue
Block a user