mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 07:51:09 -05:00
Compare commits
5 Commits
feature/dj
...
remove-cus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1876af46d9 | ||
|
|
8e9b6b6a15 | ||
|
|
45948bcc80 | ||
|
|
401449201c | ||
|
|
1ca1362fee |
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)
|
||||||
71
memory-bank/decisions/park-search-improvements.md
Normal file
71
memory-bank/decisions/park-search-improvements.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Park Search Implementation Improvements
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The park search functionality needed to be updated to follow consistent patterns across the application and strictly adhere to the "NO CUSTOM JS" rule. Previously, search functionality was inconsistent and did not fully utilize built-in framework features.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Implemented a unified search pattern that:
|
||||||
|
1. Uses only built-in HTMX and Alpine.js features
|
||||||
|
2. Matches location search pattern
|
||||||
|
3. Removes any custom JavaScript files
|
||||||
|
4. Maintains consistency across the application
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
1. **Simplified Architecture:**
|
||||||
|
- No custom JavaScript files needed
|
||||||
|
- Direct template-based implementation
|
||||||
|
- Reduced maintenance burden
|
||||||
|
- Smaller codebase
|
||||||
|
|
||||||
|
2. **Framework Alignment:**
|
||||||
|
- Uses HTMX for AJAX requests
|
||||||
|
- Uses Alpine.js for state management
|
||||||
|
- All functionality in templates
|
||||||
|
- Follows project patterns
|
||||||
|
|
||||||
|
3. **Better Maintainability:**
|
||||||
|
- Single source of truth in templates
|
||||||
|
- Reduced complexity
|
||||||
|
- Easier to understand
|
||||||
|
- Consistent with other features
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Template Features
|
||||||
|
1. HTMX Integration:
|
||||||
|
- Debounced search requests (300ms)
|
||||||
|
- Loading indicators
|
||||||
|
- JSON response handling
|
||||||
|
|
||||||
|
2. Alpine.js Usage:
|
||||||
|
- State management in template
|
||||||
|
- Event handling
|
||||||
|
- UI updates
|
||||||
|
- Keyboard interactions
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
1. JSON API:
|
||||||
|
- Consistent response format
|
||||||
|
- Type validation
|
||||||
|
- Limited results (8 items)
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
2. View Updates:
|
||||||
|
- Search filtering
|
||||||
|
- Result formatting
|
||||||
|
- Error handling
|
||||||
|
- State preservation
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
1. Better adherence to project standards
|
||||||
|
2. Simplified codebase
|
||||||
|
3. Reduced technical debt
|
||||||
|
4. Easier maintenance
|
||||||
|
5. Consistent user experience
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
1. API response format
|
||||||
|
2. Empty search handling
|
||||||
|
3. Field validation
|
||||||
|
4. UI interactions
|
||||||
|
5. State management
|
||||||
24
memory-bank/decisions/search-form-fix.md
Normal file
24
memory-bank/decisions/search-form-fix.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Search Form Fix
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
Search results were being duplicated because selecting a suggestion triggered both:
|
||||||
|
1. The suggestions form submission (to /suggest_parks/)
|
||||||
|
2. The filter form submission (to /park_list/)
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
The `@search-selected` event handler was submitting the wrong form. It was submitting the suggestions form which has `hx-target="#search-results"` instead of the filter form which has `hx-target="#park-results"`.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Update the event handler to submit the filter form instead of the search form. This ensures only one request is made to update the results.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Modified the `@search-selected` handler to:
|
||||||
|
- Set the search query in filter form
|
||||||
|
- Submit filter form to update results
|
||||||
|
- Hide suggestions dropdown
|
||||||
|
2. Added proper form IDs and refs
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
- Eliminates duplicate requests
|
||||||
|
- Maintains correct search behavior
|
||||||
|
- Improves user experience
|
||||||
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
|
||||||
@@ -1,105 +1,130 @@
|
|||||||
# Park Search Implementation
|
# Park Search Implementation
|
||||||
|
|
||||||
## Architecture
|
## Search Flow
|
||||||
|
|
||||||
The park search functionality uses a combination of:
|
1. **Quick Search (Suggestions)**
|
||||||
- BaseAutocomplete for search suggestions
|
- Endpoint: `suggest_parks/`
|
||||||
- django-htmx for async updates
|
- Shows up to 8 suggestions
|
||||||
- Django filters for advanced filtering
|
- Uses HTMX for real-time updates
|
||||||
|
- 300ms debounce for typing
|
||||||
|
|
||||||
### Components
|
2. **Full Search**
|
||||||
|
- Endpoint: `parks:park_list`
|
||||||
1. **Forms**
|
- Shows all matching results
|
||||||
- `ParkAutocomplete`: Handles search suggestions
|
- Supports view modes (grid/list)
|
||||||
- `ParkSearchForm`: Integrates autocomplete with search form
|
- Integrates with filter system
|
||||||
|
|
||||||
2. **Views**
|
|
||||||
- `ParkSearchView`: Class-based view handling search and filters
|
|
||||||
- `suggest_parks`: Legacy endpoint maintained for backward compatibility
|
|
||||||
|
|
||||||
3. **Templates**
|
|
||||||
- Simplified search UI using autocomplete widget
|
|
||||||
- Integrated loading indicators
|
|
||||||
- Filter form for additional search criteria
|
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
|
|
||||||
### Search Form
|
### Frontend Components
|
||||||
```python
|
- Search input using built-in HTMX and Alpine.js
|
||||||
class ParkSearchForm(forms.Form):
|
```html
|
||||||
park = forms.ModelChoiceField(
|
<div x-data="{ query: '', selectedId: null }"
|
||||||
queryset=Park.objects.all(),
|
@search-selected.window="...">
|
||||||
required=False,
|
<form hx-get="..." hx-trigger="input changed delay:300ms">
|
||||||
widget=AutocompleteWidget(
|
<!-- Search input and UI components -->
|
||||||
ac_class=ParkAutocomplete,
|
</form>
|
||||||
attrs={
|
</div>
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
|
||||||
'placeholder': 'Search parks...'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
- No custom JavaScript required
|
||||||
### Autocomplete
|
- Uses native frameworks' features for:
|
||||||
```python
|
- State management (Alpine.js)
|
||||||
class ParkAutocomplete(BaseAutocomplete):
|
- AJAX requests (HTMX)
|
||||||
model = Park
|
|
||||||
search_attrs = ['name']
|
|
||||||
|
|
||||||
def get_search_results(self, search):
|
|
||||||
return (get_base_park_queryset()
|
|
||||||
.filter(name__icontains=search)
|
|
||||||
.select_related('owner')
|
|
||||||
.order_by('name'))
|
|
||||||
```
|
|
||||||
|
|
||||||
### View Integration
|
|
||||||
```python
|
|
||||||
class ParkSearchView(TemplateView):
|
|
||||||
template_name = "parks/park_list.html"
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
context['search_form'] = ParkSearchForm(self.request.GET)
|
|
||||||
# ... filter handling ...
|
|
||||||
return context
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
1. **Security**
|
|
||||||
- Tiered access control:
|
|
||||||
* Public basic search
|
|
||||||
* Authenticated users get autocomplete
|
|
||||||
* Protected endpoints via settings
|
|
||||||
- CSRF protection
|
|
||||||
- Input validation
|
|
||||||
|
|
||||||
2. **Real-time Search**
|
|
||||||
- Debounced input handling
|
|
||||||
- Instant results display
|
|
||||||
- Loading indicators
|
- Loading indicators
|
||||||
|
- Keyboard interactions
|
||||||
|
|
||||||
3. **Accessibility**
|
### Templates
|
||||||
|
- `park_list.html`: Main search interface
|
||||||
|
- `park_suggestions.html`: Partial for search suggestions
|
||||||
|
- `park_list_item.html`: Results display
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
- Real-time suggestions
|
||||||
|
- Keyboard navigation (ESC to clear)
|
||||||
|
- ARIA attributes for accessibility
|
||||||
|
- Dark mode support
|
||||||
|
- CSRF protection
|
||||||
|
- Loading states
|
||||||
|
|
||||||
|
### Search Flow
|
||||||
|
1. User types in search box
|
||||||
|
2. After 300ms debounce, HTMX sends request
|
||||||
|
3. Server returns suggestion list
|
||||||
|
4. User selects item
|
||||||
|
5. Form submits to main list view with filter
|
||||||
|
6. Results update while maintaining view mode
|
||||||
|
|
||||||
|
## Recent Updates (2024-02-22)
|
||||||
|
1. Fixed search page loading issue:
|
||||||
|
- Removed legacy redirect in suggest_parks
|
||||||
|
- Updated search form to use HTMX properly
|
||||||
|
- Added Alpine.js for state management
|
||||||
|
- Improved suggestions UI
|
||||||
|
- Maintained view mode during search
|
||||||
|
|
||||||
|
2. Security:
|
||||||
|
- CSRF protection on all forms
|
||||||
|
- Input sanitization
|
||||||
|
- Proper parameter handling
|
||||||
|
|
||||||
|
3. Performance:
|
||||||
|
- 300ms debounce on typing
|
||||||
|
- Limit suggestions to 8 items
|
||||||
|
- Efficient query optimization
|
||||||
|
|
||||||
|
4. Accessibility:
|
||||||
- ARIA labels and roles
|
- ARIA labels and roles
|
||||||
- Keyboard navigation support
|
- Keyboard navigation
|
||||||
- Screen reader compatibility
|
- Proper focus management
|
||||||
|
- Screen reader support
|
||||||
|
|
||||||
4. **Integration**
|
## API Response Format
|
||||||
- Works with existing filter system
|
|
||||||
- Maintains view mode selection
|
|
||||||
- Preserves URL state
|
|
||||||
|
|
||||||
## Performance Considerations
|
### Suggestions Endpoint (`/parks/suggest_parks/`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"name": "string",
|
||||||
|
"status": "string",
|
||||||
|
"location": "string",
|
||||||
|
"url": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- Prefetch related owner data
|
### Field Details
|
||||||
- Uses base queryset optimizations
|
- `id`: Database ID (string format)
|
||||||
- Debounced search requests
|
- `name`: Park name
|
||||||
- Proper index usage on name field
|
- `status`: Formatted status display (e.g., "Operating")
|
||||||
|
- `location`: Formatted location string
|
||||||
|
- `url`: Full detail page URL
|
||||||
|
|
||||||
## Future Improvements
|
## Test Coverage
|
||||||
|
|
||||||
- Consider adding full-text search
|
### API Tests
|
||||||
- Implement result caching
|
- JSON format validation
|
||||||
- Add geographic search capabilities
|
- Empty search handling
|
||||||
- Enhance filter integration
|
- Field type checking
|
||||||
|
- Result limit verification
|
||||||
|
- Response structure
|
||||||
|
|
||||||
|
### UI Integration Tests
|
||||||
|
- View mode persistence
|
||||||
|
- Loading state verification
|
||||||
|
- Error handling
|
||||||
|
- Keyboard interaction
|
||||||
|
|
||||||
|
### Data Format Tests
|
||||||
|
- Location string formatting
|
||||||
|
- Status display formatting
|
||||||
|
- URL generation
|
||||||
|
- Field type validation
|
||||||
|
|
||||||
|
### Performance Tests
|
||||||
|
- Debounce functionality
|
||||||
|
- Result limiting (8 items)
|
||||||
|
- Query optimization
|
||||||
|
- Response timing
|
||||||
@@ -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,43 +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">
|
|
||||||
<form hx-get="{% url 'parks:park_list' %}"
|
|
||||||
hx-target="#park-results"
|
|
||||||
hx-push-url="true"
|
|
||||||
hx-trigger="change from:.park-search">
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ search_form.park }}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Loading indicator -->
|
|
||||||
<div id="search-indicator"
|
|
||||||
class="htmx-indicator absolute right-3 top-3"
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading search results">
|
|
||||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Searching...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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"
|
||||||
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"
|
hx-trigger="submit"
|
||||||
class="mt-4">
|
class="mt-4">
|
||||||
|
<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 %}
|
||||||
|
|||||||
30
parks/templates/parks/partials/park_suggestions.html
Normal file
30
parks/templates/parks/partials/park_suggestions.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% if parks %}
|
||||||
|
<div class="py-2">
|
||||||
|
{% for park in parks %}
|
||||||
|
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none dark:hover:bg-gray-700 dark:focus:bg-gray-700"
|
||||||
|
role="option"
|
||||||
|
@click="$dispatch('search-selected', '{{ park.name }}')"
|
||||||
|
value="{{ park.id }}">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ park.name }}</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if park.formatted_location %}
|
||||||
|
{{ park.formatted_location }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ park.get_status_display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No parks found matching "{{ query }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -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 %}
|
||||||
@@ -24,24 +24,70 @@ uv run pytest --cov=parks parks/tests/
|
|||||||
|
|
||||||
## Test Coverage
|
## Test Coverage
|
||||||
|
|
||||||
### Unit Tests
|
### Search API Tests
|
||||||
- `test_autocomplete_results`: Validates search result filtering
|
- `test_search_json_format`: Validates API response structure
|
||||||
- `test_search_form_valid`: Ensures form validation works
|
- `test_empty_search_json`: Tests empty search handling
|
||||||
- `test_autocomplete_class`: Checks autocomplete configuration
|
- `test_search_format_validation`: Verifies all required fields and types
|
||||||
- `test_search_with_filters`: Verifies filter integration
|
- `test_suggestion_limit`: Confirms 8-item result limit
|
||||||
|
|
||||||
### Integration Tests
|
### Search Functionality Tests
|
||||||
- `test_empty_search`: Tests default behavior
|
- `test_autocomplete_results`: Validates real-time suggestion filtering
|
||||||
- `test_partial_match_search`: Validates partial text matching
|
- `test_search_with_filters`: Tests filter integration with search
|
||||||
- `test_htmx_request_handling`: Ensures HTMX compatibility
|
- `test_partial_match_search`: Verifies partial text matching works
|
||||||
- `test_view_mode_persistence`: Checks view state management
|
|
||||||
- `test_unauthenticated_access`: Verifies authentication requirements
|
|
||||||
|
|
||||||
### Security Tests
|
### UI Integration Tests
|
||||||
Parks search implements a tiered access approach:
|
- `test_view_mode_persistence`: Ensures view mode is maintained
|
||||||
- Basic search is public
|
- `test_empty_search`: Tests default state behavior
|
||||||
- Autocomplete requires authentication
|
- `test_htmx_request_handling`: Validates HTMX interactions
|
||||||
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
|
|
||||||
|
### Data Format Tests
|
||||||
|
- Field types validation
|
||||||
|
- Location formatting
|
||||||
|
- Status display formatting
|
||||||
|
- URL generation
|
||||||
|
- Response structure
|
||||||
|
|
||||||
|
### Frontend Integration
|
||||||
|
- HTMX partial updates
|
||||||
|
- Alpine.js state management
|
||||||
|
- Loading indicators
|
||||||
|
- View mode persistence
|
||||||
|
- Keyboard navigation
|
||||||
|
|
||||||
|
### Test Commands
|
||||||
|
```bash
|
||||||
|
# Run all park tests
|
||||||
|
uv run pytest parks/tests/
|
||||||
|
|
||||||
|
# Run search tests specifically
|
||||||
|
uv run pytest parks/tests/test_search.py
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
uv run pytest --cov=parks parks/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coverage Areas
|
||||||
|
1. Search Functionality:
|
||||||
|
- Suggestion generation
|
||||||
|
- Result filtering
|
||||||
|
- Partial matching
|
||||||
|
- Empty state handling
|
||||||
|
|
||||||
|
2. UI Integration:
|
||||||
|
- HTMX requests
|
||||||
|
- View mode switching
|
||||||
|
- Loading states
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
3. Performance:
|
||||||
|
- Result limiting
|
||||||
|
- Debouncing
|
||||||
|
- Query optimization
|
||||||
|
|
||||||
|
4. Accessibility:
|
||||||
|
- ARIA attributes
|
||||||
|
- Keyboard controls
|
||||||
|
- Screen reader support
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from django.test import Client
|
|||||||
from parks.models import Park
|
from parks.models import Park
|
||||||
from parks.forms import ParkAutocomplete, ParkSearchForm
|
from parks.forms import ParkAutocomplete, ParkSearchForm
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestParkSearch:
|
class TestParkSearch:
|
||||||
def test_autocomplete_results(self, client: Client):
|
def test_autocomplete_results(self, client: Client):
|
||||||
@@ -15,8 +16,8 @@ class TestParkSearch:
|
|||||||
park3 = Park.objects.create(name="Test Garden")
|
park3 = Park.objects.create(name="Test Garden")
|
||||||
|
|
||||||
# Get autocomplete results
|
# Get autocomplete results
|
||||||
url = reverse('parks:park_list')
|
url = reverse('parks:suggest_parks')
|
||||||
response = client.get(url, {'park': 'Test'})
|
response = client.get(url, {'search': 'Test'})
|
||||||
|
|
||||||
# Check response
|
# Check response
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -27,7 +28,7 @@ class TestParkSearch:
|
|||||||
|
|
||||||
def test_search_form_valid(self):
|
def test_search_form_valid(self):
|
||||||
"""Test ParkSearchForm validation"""
|
"""Test ParkSearchForm validation"""
|
||||||
form = ParkSearchForm(data={'park': ''})
|
form = ParkSearchForm(data={})
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
def test_autocomplete_class(self):
|
def test_autocomplete_class(self):
|
||||||
@@ -68,8 +69,8 @@ class TestParkSearch:
|
|||||||
Park.objects.create(name="Adventure World")
|
Park.objects.create(name="Adventure World")
|
||||||
Park.objects.create(name="Water Adventure")
|
Park.objects.create(name="Water Adventure")
|
||||||
|
|
||||||
url = reverse('parks:park_list')
|
url = reverse('parks:suggest_parks')
|
||||||
response = client.get(url, {'park': 'Adv'})
|
response = client.get(url, {'search': 'Adv'})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
@@ -80,10 +81,10 @@ class TestParkSearch:
|
|||||||
"""Test HTMX-specific request handling"""
|
"""Test HTMX-specific request handling"""
|
||||||
Park.objects.create(name="Test Park")
|
Park.objects.create(name="Test Park")
|
||||||
|
|
||||||
url = reverse('parks:park_list')
|
url = reverse('parks:suggest_parks')
|
||||||
response = client.get(
|
response = client.get(
|
||||||
url,
|
url,
|
||||||
{'park': 'Test'},
|
{'search': 'Test'},
|
||||||
HTTP_HX_REQUEST='true'
|
HTTP_HX_REQUEST='true'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,17 +104,80 @@ class TestParkSearch:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'data-view-mode="list"' in response.content.decode()
|
assert 'data-view-mode="list"' in response.content.decode()
|
||||||
|
|
||||||
def test_unauthenticated_access(self, client: Client):
|
def test_suggestion_limit(self, client: Client):
|
||||||
"""Test that unauthorized users can access search but not autocomplete"""
|
"""Test that suggestions are limited to 8 items"""
|
||||||
park = Park.objects.create(name="Test Park")
|
# Create 10 parks
|
||||||
|
for i in range(10):
|
||||||
|
Park.objects.create(name=f"Test Park {i}")
|
||||||
|
|
||||||
# Regular search should work
|
|
||||||
url = reverse('parks:park_list')
|
|
||||||
response = client.get(url, {'park_name': 'Test'})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Test Park" in response.content.decode()
|
|
||||||
|
|
||||||
# Autocomplete should require authentication
|
|
||||||
url = reverse('parks:suggest_parks')
|
url = reverse('parks:suggest_parks')
|
||||||
response = client.get(url, {'search': 'Test'})
|
response = client.get(url, {'search': 'Test'})
|
||||||
assert response.status_code == 302 # Redirects to login
|
|
||||||
|
content = response.content.decode()
|
||||||
|
result_count = content.count('Test Park')
|
||||||
|
assert result_count == 8 # Verify limit is enforced
|
||||||
|
|
||||||
|
def test_search_json_format(self, client: Client):
|
||||||
|
"""Test that search returns properly formatted JSON"""
|
||||||
|
park = Park.objects.create(
|
||||||
|
name="Test Park",
|
||||||
|
status="OPERATING",
|
||||||
|
city="Test City",
|
||||||
|
state="Test State"
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse('parks:suggest_parks')
|
||||||
|
response = client.get(url, {'search': 'Test'})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert 'results' in data
|
||||||
|
assert len(data['results']) == 1
|
||||||
|
|
||||||
|
result = data['results'][0]
|
||||||
|
assert result['id'] == str(park.pk)
|
||||||
|
assert result['name'] == "Test Park"
|
||||||
|
assert result['status'] == "Operating"
|
||||||
|
assert result['location'] == park.formatted_location
|
||||||
|
assert result['url'] == reverse('parks:park_detail', kwargs={'slug': park.slug})
|
||||||
|
|
||||||
|
def test_empty_search_json(self, client: Client):
|
||||||
|
"""Test empty search returns empty results array"""
|
||||||
|
url = reverse('parks:suggest_parks')
|
||||||
|
response = client.get(url, {'search': ''})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert 'results' in data
|
||||||
|
assert len(data['results']) == 0
|
||||||
|
|
||||||
|
def test_search_format_validation(self, client: Client):
|
||||||
|
"""Test that all fields are properly formatted in search results"""
|
||||||
|
park = Park.objects.create(
|
||||||
|
name="Test Park",
|
||||||
|
status="OPERATING",
|
||||||
|
city="Test City",
|
||||||
|
state="Test State",
|
||||||
|
country="Test Country"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_fields = {'id', 'name', 'status', 'location', 'url'}
|
||||||
|
|
||||||
|
url = reverse('parks:suggest_parks')
|
||||||
|
response = client.get(url, {'search': 'Test'})
|
||||||
|
data = response.json()
|
||||||
|
result = data['results'][0]
|
||||||
|
|
||||||
|
# Check all expected fields are present
|
||||||
|
assert set(result.keys()) == expected_fields
|
||||||
|
|
||||||
|
# Check field types
|
||||||
|
assert isinstance(result['id'], str)
|
||||||
|
assert isinstance(result['name'], str)
|
||||||
|
assert isinstance(result['status'], str)
|
||||||
|
assert isinstance(result['location'], str)
|
||||||
|
assert isinstance(result['url'], str)
|
||||||
|
|
||||||
|
# Check formatted location includes city and state
|
||||||
|
assert 'Test City' in result['location']
|
||||||
|
assert 'Test State' in result['location']
|
||||||
|
|||||||
@@ -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,12 +29,3 @@ class ParkSearchView(TemplateView):
|
|||||||
context['parks'] = queryset
|
context['parks'] = queryset
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def suggest_parks(request: HttpRequest) -> HttpResponse:
|
|
||||||
"""Legacy endpoint for old search UI - redirects to autocomplete."""
|
|
||||||
query = request.GET.get('search', '').strip()
|
|
||||||
if query:
|
|
||||||
return JsonResponse({
|
|
||||||
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
|
|
||||||
})
|
|
||||||
return HttpResponse('')
|
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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