mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 16:51:07 -05:00
Compare commits
5 Commits
feature/dj
...
remove-cus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1876af46d9 | ||
|
|
8e9b6b6a15 | ||
|
|
45948bcc80 | ||
|
|
401449201c | ||
|
|
1ca1362fee |
15
.clinerules
15
.clinerules
@@ -27,4 +27,17 @@ This applies to all management commands including but not limited to:
|
||||
- Creating superuser: `uv run manage.py createsuperuser`
|
||||
- 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
|
||||
|
||||
## Architecture
|
||||
## Search Flow
|
||||
|
||||
The park search functionality uses a combination of:
|
||||
- BaseAutocomplete for search suggestions
|
||||
- django-htmx for async updates
|
||||
- Django filters for advanced filtering
|
||||
1. **Quick Search (Suggestions)**
|
||||
- Endpoint: `suggest_parks/`
|
||||
- Shows up to 8 suggestions
|
||||
- Uses HTMX for real-time updates
|
||||
- 300ms debounce for typing
|
||||
|
||||
### Components
|
||||
|
||||
1. **Forms**
|
||||
- `ParkAutocomplete`: Handles search suggestions
|
||||
- `ParkSearchForm`: Integrates autocomplete with search form
|
||||
|
||||
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
|
||||
2. **Full Search**
|
||||
- Endpoint: `parks:park_list`
|
||||
- Shows all matching results
|
||||
- Supports view modes (grid/list)
|
||||
- Integrates with filter system
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Search Form
|
||||
```python
|
||||
class ParkSearchForm(forms.Form):
|
||||
park = forms.ModelChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=False,
|
||||
widget=AutocompleteWidget(
|
||||
ac_class=ParkAutocomplete,
|
||||
attrs={
|
||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
'placeholder': 'Search parks...'
|
||||
}
|
||||
)
|
||||
)
|
||||
### Frontend Components
|
||||
- Search input using built-in HTMX and Alpine.js
|
||||
```html
|
||||
<div x-data="{ query: '', selectedId: null }"
|
||||
@search-selected.window="...">
|
||||
<form hx-get="..." hx-trigger="input changed delay:300ms">
|
||||
<!-- Search input and UI components -->
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
- No custom JavaScript required
|
||||
- Uses native frameworks' features for:
|
||||
- State management (Alpine.js)
|
||||
- AJAX requests (HTMX)
|
||||
- Loading indicators
|
||||
- Keyboard interactions
|
||||
|
||||
### 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
|
||||
- Keyboard navigation
|
||||
- Proper focus management
|
||||
- Screen reader support
|
||||
|
||||
## API Response Format
|
||||
|
||||
### Suggestions Endpoint (`/parks/suggest_parks/`)
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"status": "string",
|
||||
"location": "string",
|
||||
"url": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Autocomplete
|
||||
```python
|
||||
class ParkAutocomplete(BaseAutocomplete):
|
||||
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'))
|
||||
```
|
||||
### Field Details
|
||||
- `id`: Database ID (string format)
|
||||
- `name`: Park name
|
||||
- `status`: Formatted status display (e.g., "Operating")
|
||||
- `location`: Formatted location string
|
||||
- `url`: Full detail page URL
|
||||
|
||||
### 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
|
||||
```
|
||||
## Test Coverage
|
||||
|
||||
## Features
|
||||
### API Tests
|
||||
- JSON format validation
|
||||
- Empty search handling
|
||||
- Field type checking
|
||||
- Result limit verification
|
||||
- Response structure
|
||||
|
||||
1. **Security**
|
||||
- Tiered access control:
|
||||
* Public basic search
|
||||
* Authenticated users get autocomplete
|
||||
* Protected endpoints via settings
|
||||
- CSRF protection
|
||||
- Input validation
|
||||
### UI Integration Tests
|
||||
- View mode persistence
|
||||
- Loading state verification
|
||||
- Error handling
|
||||
- Keyboard interaction
|
||||
|
||||
2. **Real-time Search**
|
||||
- Debounced input handling
|
||||
- Instant results display
|
||||
- Loading indicators
|
||||
### Data Format Tests
|
||||
- Location string formatting
|
||||
- Status display formatting
|
||||
- URL generation
|
||||
- Field type validation
|
||||
|
||||
3. **Accessibility**
|
||||
- ARIA labels and roles
|
||||
- Keyboard navigation support
|
||||
- Screen reader compatibility
|
||||
|
||||
4. **Integration**
|
||||
- Works with existing filter system
|
||||
- Maintains view mode selection
|
||||
- Preserves URL state
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Prefetch related owner data
|
||||
- Uses base queryset optimizations
|
||||
- Debounced search requests
|
||||
- Proper index usage on name field
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- Consider adding full-text search
|
||||
- Implement result caching
|
||||
- Add geographic search capabilities
|
||||
- Enhance filter integration
|
||||
### Performance Tests
|
||||
- Debounce functionality
|
||||
- Result limiting (8 items)
|
||||
- Query optimization
|
||||
- Response timing
|
||||
@@ -21,6 +21,23 @@
|
||||
- Implement component-based structure
|
||||
- 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
|
||||
|
||||
### 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 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 location.models import Location
|
||||
from .querysets import get_base_park_queryset
|
||||
|
||||
|
||||
class ParkAutocomplete(BaseAutocomplete):
|
||||
class ParkAutocomplete(ModelAutocomplete):
|
||||
"""Autocomplete for searching parks.
|
||||
|
||||
Features:
|
||||
@@ -19,6 +18,8 @@ class ParkAutocomplete(BaseAutocomplete):
|
||||
"""
|
||||
model = Park
|
||||
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):
|
||||
"""Return search results with related data."""
|
||||
|
||||
@@ -47,42 +47,23 @@
|
||||
{% block filter_section %}
|
||||
<div class="mb-6">
|
||||
<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 class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<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 }}
|
||||
</div>
|
||||
{% include "search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
|
||||
<form id="filter-form"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change"
|
||||
class="mt-4">
|
||||
{% include "search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
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 suggestions %}
|
||||
<div id="search-suggestions-results"
|
||||
class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
|
||||
x-show="open"
|
||||
x-cloak
|
||||
@keydown.escape.window="open = false"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
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>
|
||||
</div>
|
||||
</button>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if results %}
|
||||
<div class="py-1">
|
||||
{% for result in results %}
|
||||
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@click="$dispatch('search-selected', '{{ result.name }}')"
|
||||
value="{{ result.id }}"
|
||||
role="option">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ result.name }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ result.status }}{% if result.location %} • {{ result.location }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
@@ -24,24 +24,70 @@ uv run pytest --cov=parks parks/tests/
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Unit Tests
|
||||
- `test_autocomplete_results`: Validates search result filtering
|
||||
- `test_search_form_valid`: Ensures form validation works
|
||||
- `test_autocomplete_class`: Checks autocomplete configuration
|
||||
- `test_search_with_filters`: Verifies filter integration
|
||||
### Search API Tests
|
||||
- `test_search_json_format`: Validates API response structure
|
||||
- `test_empty_search_json`: Tests empty search handling
|
||||
- `test_search_format_validation`: Verifies all required fields and types
|
||||
- `test_suggestion_limit`: Confirms 8-item result limit
|
||||
|
||||
### Integration Tests
|
||||
- `test_empty_search`: Tests default behavior
|
||||
- `test_partial_match_search`: Validates partial text matching
|
||||
- `test_htmx_request_handling`: Ensures HTMX compatibility
|
||||
- `test_view_mode_persistence`: Checks view state management
|
||||
- `test_unauthenticated_access`: Verifies authentication requirements
|
||||
### Search Functionality Tests
|
||||
- `test_autocomplete_results`: Validates real-time suggestion filtering
|
||||
- `test_search_with_filters`: Tests filter integration with search
|
||||
- `test_partial_match_search`: Verifies partial text matching works
|
||||
|
||||
### Security Tests
|
||||
Parks search implements a tiered access approach:
|
||||
- Basic search is public
|
||||
- Autocomplete requires authentication
|
||||
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
|
||||
### UI Integration Tests
|
||||
- `test_view_mode_persistence`: Ensures view mode is maintained
|
||||
- `test_empty_search`: Tests default state behavior
|
||||
- `test_htmx_request_handling`: Validates HTMX interactions
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.test import Client
|
||||
from parks.models import Park
|
||||
from parks.forms import ParkAutocomplete, ParkSearchForm
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkSearch:
|
||||
def test_autocomplete_results(self, client: Client):
|
||||
@@ -13,11 +14,11 @@ class TestParkSearch:
|
||||
park1 = Park.objects.create(name="Test Park")
|
||||
park2 = Park.objects.create(name="Another Park")
|
||||
park3 = Park.objects.create(name="Test Garden")
|
||||
|
||||
|
||||
# Get autocomplete results
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {'park': 'Test'})
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Test'})
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
@@ -27,7 +28,7 @@ class TestParkSearch:
|
||||
|
||||
def test_search_form_valid(self):
|
||||
"""Test ParkSearchForm validation"""
|
||||
form = ParkSearchForm(data={'park': ''})
|
||||
form = ParkSearchForm(data={})
|
||||
assert form.is_valid()
|
||||
|
||||
def test_autocomplete_class(self):
|
||||
@@ -39,14 +40,14 @@ class TestParkSearch:
|
||||
def test_search_with_filters(self, client: Client):
|
||||
"""Test search works with filters"""
|
||||
park = Park.objects.create(name="Test Park", status="OPERATING")
|
||||
|
||||
|
||||
# Search with status filter
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {
|
||||
'park': str(park.pk),
|
||||
'status': 'OPERATING'
|
||||
})
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert park.name in response.content.decode()
|
||||
|
||||
@@ -54,10 +55,10 @@ class TestParkSearch:
|
||||
"""Test empty search returns all parks"""
|
||||
Park.objects.create(name="Test Park")
|
||||
Park.objects.create(name="Another Park")
|
||||
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Test Park" in content
|
||||
@@ -67,10 +68,10 @@ class TestParkSearch:
|
||||
"""Test partial matching in search"""
|
||||
Park.objects.create(name="Adventure World")
|
||||
Park.objects.create(name="Water Adventure")
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {'park': 'Adv'})
|
||||
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Adv'})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Adventure World" in content
|
||||
@@ -79,41 +80,104 @@ class TestParkSearch:
|
||||
def test_htmx_request_handling(self, client: Client):
|
||||
"""Test HTMX-specific request handling"""
|
||||
Park.objects.create(name="Test Park")
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(
|
||||
url,
|
||||
{'park': 'Test'},
|
||||
url,
|
||||
{'search': 'Test'},
|
||||
HTTP_HX_REQUEST='true'
|
||||
)
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Test Park" in response.content.decode()
|
||||
|
||||
def test_view_mode_persistence(self, client: Client):
|
||||
"""Test view mode is maintained during search"""
|
||||
Park.objects.create(name="Test Park")
|
||||
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {
|
||||
'park': 'Test',
|
||||
'view_mode': 'list'
|
||||
})
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-view-mode="list"' in response.content.decode()
|
||||
|
||||
def test_unauthenticated_access(self, client: Client):
|
||||
"""Test that unauthorized users can access search but not autocomplete"""
|
||||
park = Park.objects.create(name="Test Park")
|
||||
|
||||
# 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
|
||||
def test_suggestion_limit(self, client: Client):
|
||||
"""Test that suggestions are limited to 8 items"""
|
||||
# Create 10 parks
|
||||
for i in range(10):
|
||||
Park.objects.create(name=f"Test Park {i}")
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
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
|
||||
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"),
|
||||
|
||||
# Park detail and related views
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views.generic import TemplateView
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -13,6 +12,8 @@ class ParkSearchView(TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Initialize search form
|
||||
context['search_form'] = ParkSearchForm(self.request.GET)
|
||||
|
||||
# Initialize filter with current querystring
|
||||
@@ -20,24 +21,11 @@ class ParkSearchView(TemplateView):
|
||||
filter_instance = ParkFilter(self.request.GET, queryset=queryset)
|
||||
context['filter'] = filter_instance
|
||||
|
||||
# Apply search if park ID selected via autocomplete
|
||||
park_id = self.request.GET.get('park')
|
||||
if park_id:
|
||||
queryset = filter_instance.qs.filter(id=park_id)
|
||||
else:
|
||||
queryset = filter_instance.qs
|
||||
# Get filtered queryset
|
||||
queryset = filter_instance.qs
|
||||
|
||||
# Handle view mode
|
||||
context['view_mode'] = self.request.GET.get('view_mode', 'grid')
|
||||
context['parks'] = queryset
|
||||
|
||||
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('')
|
||||
return context
|
||||
@@ -1,44 +1,37 @@
|
||||
/* Alert Styles */
|
||||
.alert {
|
||||
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4;
|
||||
animation: slideIn 0.5s ease-out forwards;
|
||||
padding: 1rem;
|
||||
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 {
|
||||
@apply text-white bg-green-500;
|
||||
background-color: #E8F5E9;
|
||||
border: 1px solid #A5D6A7;
|
||||
color: #2E7D32;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
@apply text-white bg-red-500;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
@apply text-white bg-blue-500;
|
||||
background-color: #FFEBEE;
|
||||
border: 1px solid #FFCDD2;
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
@apply text-white bg-yellow-500;
|
||||
background-color: #FFF3E0;
|
||||
border: 1px solid #FFCC80;
|
||||
color: #EF6C00;
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes slideIn {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.alert-info {
|
||||
background-color: #E3F2FD;
|
||||
border: 1px solid #90CAF9;
|
||||
color: #1565C0;
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.alert.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get all alert elements
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const alerts = document.querySelectorAll('.alert');
|
||||
|
||||
// For each alert
|
||||
|
||||
alerts.forEach(alert => {
|
||||
// After 5 seconds
|
||||
// Auto-hide alerts after 5 seconds
|
||||
setTimeout(() => {
|
||||
// Add slideOut animation
|
||||
alert.style.animation = 'slideOut 0.5s ease-out forwards';
|
||||
|
||||
// Remove the alert after animation completes
|
||||
alert.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 500);
|
||||
}, 300); // Match CSS transition duration
|
||||
}, 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) {
|
||||
return {
|
||||
query: '',
|
||||
suggestions: [],
|
||||
fetchSuggestions() {
|
||||
let url;
|
||||
const params = new URLSearchParams({
|
||||
q: this.query,
|
||||
filter_parks: filterParks
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const countryInput = document.querySelector('[name="country"]');
|
||||
const regionInput = document.querySelector('[name="region"]');
|
||||
const cityInput = document.querySelector('[name="city"]');
|
||||
|
||||
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 (!countryInput || !regionInput || !cityInput) return;
|
||||
|
||||
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');
|
||||
}
|
||||
// Update regions when country changes
|
||||
countryInput.addEventListener('change', () => {
|
||||
const country = countryInput.value;
|
||||
if (country) {
|
||||
updateRegions(country);
|
||||
// Clear city when country changes
|
||||
cityInput.innerHTML = '<option value="">Select a city</option>';
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Update cities when region changes
|
||||
regionInput.addEventListener('change', () => {
|
||||
const country = countryInput.value;
|
||||
const region = regionInput.value;
|
||||
if (country && region) {
|
||||
updateCities(country, region);
|
||||
}
|
||||
});
|
||||
|
||||
function updateRegions(country) {
|
||||
fetch(`/location/regions/?country=${encodeURIComponent(country)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
regionInput.innerHTML = '<option value="">Select a region</option>';
|
||||
data.regions.forEach(region => {
|
||||
const option = new Option(region, region);
|
||||
regionInput.add(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateCities(country, region) {
|
||||
fetch(`/location/cities/?country=${encodeURIComponent(country)}®ion=${encodeURIComponent(region)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
cityInput.innerHTML = '<option value="">Select a city</option>';
|
||||
data.cities.forEach(city => {
|
||||
const option = new Option(city, city);
|
||||
cityInput.add(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,141 +1,40 @@
|
||||
// Theme handling
|
||||
// Theme Toggle
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
|
||||
// Initialize toggle state based on current theme
|
||||
if (themeToggle) {
|
||||
themeToggle.checked = html.classList.contains('dark');
|
||||
|
||||
// Handle toggle changes
|
||||
themeToggle.addEventListener('change', function() {
|
||||
const isDark = this.checked;
|
||||
html.classList.toggle('dark', isDark);
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
const isDark = e.matches;
|
||||
html.classList.toggle('dark', isDark);
|
||||
themeToggle.checked = isDark;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
const themeIcon = themeToggle.nextElementSibling.querySelector('i');
|
||||
|
||||
// Handle search form submission
|
||||
document.addEventListener('submit', (e) => {
|
||||
if (e.target.matches('form[action*="search"]')) {
|
||||
const searchInput = e.target.querySelector('input[name="q"]');
|
||||
if (!searchInput.value.trim()) {
|
||||
e.preventDefault();
|
||||
// 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');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
updateThemeIcon();
|
||||
});
|
||||
|
||||
// Mobile menu toggle with transitions
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
function updateThemeIcon() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
themeIcon.classList.remove('fa-sun', 'fa-moon');
|
||||
themeIcon.classList.add(isDark ? 'fa-sun' : 'fa-moon');
|
||||
}
|
||||
|
||||
// Mobile Menu Toggle
|
||||
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
|
||||
const mobileMenu = document.getElementById('mobileMenu');
|
||||
const menuIcon = mobileMenuBtn.querySelector('i');
|
||||
|
||||
if (mobileMenuBtn && mobileMenu) {
|
||||
let isMenuOpen = false;
|
||||
mobileMenu.style.display = 'none';
|
||||
let isMenuOpen = false;
|
||||
|
||||
const toggleMenu = () => {
|
||||
isMenuOpen = !isMenuOpen;
|
||||
mobileMenu.classList.toggle('show', isMenuOpen);
|
||||
mobileMenuBtn.setAttribute('aria-expanded', isMenuOpen.toString());
|
||||
|
||||
// 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');
|
||||
mobileMenuBtn.addEventListener('click', () => {
|
||||
isMenuOpen = !isMenuOpen;
|
||||
mobileMenu.style.display = isMenuOpen ? 'block' : 'none';
|
||||
menuIcon.classList.remove('fa-bars', 'fa-times');
|
||||
menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
|
||||
});
|
||||
|
||||
// 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 {
|
||||
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4;
|
||||
animation: slideIn 0.5s ease-out forwards;
|
||||
padding: 1rem;
|
||||
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 {
|
||||
@apply text-white bg-green-500;
|
||||
background-color: #E8F5E9;
|
||||
border: 1px solid #A5D6A7;
|
||||
color: #2E7D32;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
@apply text-white bg-red-500;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
@apply text-white bg-blue-500;
|
||||
background-color: #FFEBEE;
|
||||
border: 1px solid #FFCDD2;
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
@apply text-white bg-yellow-500;
|
||||
background-color: #FFF3E0;
|
||||
border: 1px solid #FFCC80;
|
||||
color: #EF6C00;
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes slideIn {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.alert-info {
|
||||
background-color: #E3F2FD;
|
||||
border: 1px solid #90CAF9;
|
||||
color: #1565C0;
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.alert.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -2181,6 +2181,18 @@ select {
|
||||
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 {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -2457,6 +2469,10 @@ select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.h-10 {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
@@ -2485,6 +2501,10 @@ select {
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.h-8 {
|
||||
height: 2rem;
|
||||
}
|
||||
@@ -2533,6 +2553,10 @@ select {
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.w-64 {
|
||||
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));
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@@ -3000,10 +3034,6 @@ select {
|
||||
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 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
|
||||
@@ -3244,6 +3274,10 @@ select {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pt-2 {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
@@ -3335,6 +3369,11 @@ select {
|
||||
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 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
||||
@@ -3405,6 +3444,11 @@ select {
|
||||
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 {
|
||||
--tw-text-opacity: 1;
|
||||
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);
|
||||
}
|
||||
|
||||
.outline-none {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.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-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);
|
||||
}
|
||||
|
||||
.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: 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));
|
||||
}
|
||||
|
||||
.focus\:bg-gray-100:focus {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.focus\:underline:focus {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -3824,6 +3891,10 @@ select {
|
||||
--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 {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -3930,6 +4001,10 @@ select {
|
||||
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 *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
|
||||
@@ -3968,6 +4043,11 @@ select {
|
||||
--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 *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(191 219 254 / var(--tw-text-opacity));
|
||||
@@ -4190,6 +4270,11 @@ select {
|
||||
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) {
|
||||
.sm\:col-span-3 {
|
||||
grid-column: span 3 / span 3;
|
||||
@@ -4297,10 +4382,26 @@ select {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.md\:col-span-3 {
|
||||
grid-column: span 3 / span 3;
|
||||
}
|
||||
|
||||
.md\:mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.md\:block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md\:grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.md\:hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md\:h-\[140px\] {
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get all alert elements
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const alerts = document.querySelectorAll('.alert');
|
||||
|
||||
// For each alert
|
||||
|
||||
alerts.forEach(alert => {
|
||||
// After 5 seconds
|
||||
// Auto-hide alerts after 5 seconds
|
||||
setTimeout(() => {
|
||||
// Add slideOut animation
|
||||
alert.style.animation = 'slideOut 0.5s ease-out forwards';
|
||||
|
||||
// Remove the alert after animation completes
|
||||
alert.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 500);
|
||||
}, 300); // Match CSS transition duration
|
||||
}, 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', () => {
|
||||
// Check if dark mode was previously enabled
|
||||
const darkMode = localStorage.getItem('darkMode') === 'true';
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
});
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const themeIcon = themeToggle.nextElementSibling.querySelector('i');
|
||||
|
||||
// Handle search form submission
|
||||
document.addEventListener('submit', (e) => {
|
||||
if (e.target.matches('form[action*="search"]')) {
|
||||
const searchInput = e.target.querySelector('input[name="q"]');
|
||||
if (!searchInput.value.trim()) {
|
||||
e.preventDefault();
|
||||
// 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');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
updateThemeIcon();
|
||||
});
|
||||
|
||||
function updateThemeIcon() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
themeIcon.classList.remove('fa-sun', 'fa-moon');
|
||||
themeIcon.classList.add(isDark ? 'fa-sun' : 'fa-moon');
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const mobileMenu = document.querySelector('[x-show="mobileMenuOpen"]');
|
||||
const menuButton = document.querySelector('[x-on\\:click="mobileMenuOpen = !mobileMenuOpen"]');
|
||||
|
||||
if (mobileMenu && menuButton && !mobileMenu.contains(e.target) && !menuButton.contains(e.target)) {
|
||||
const alpineData = mobileMenu._x_dataStack && mobileMenu._x_dataStack[0];
|
||||
if (alpineData && alpineData.mobileMenuOpen) {
|
||||
alpineData.mobileMenuOpen = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Mobile Menu Toggle
|
||||
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
|
||||
const mobileMenu = document.getElementById('mobileMenu');
|
||||
const menuIcon = mobileMenuBtn.querySelector('i');
|
||||
|
||||
// Handle flash messages
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const alerts = document.querySelectorAll('.alert');
|
||||
alerts.forEach(alert => {
|
||||
setTimeout(() => {
|
||||
alert.style.opacity = '0';
|
||||
setTimeout(() => alert.remove(), 300);
|
||||
}, 5000);
|
||||
mobileMenu.style.display = 'none';
|
||||
let isMenuOpen = false;
|
||||
|
||||
mobileMenuBtn.addEventListener('click', () => {
|
||||
isMenuOpen = !isMenuOpen;
|
||||
mobileMenu.style.display = isMenuOpen ? 'block' : 'none';
|
||||
menuIcon.classList.remove('fa-bars', 'fa-times');
|
||||
menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
|
||||
});
|
||||
});
|
||||
|
||||
// 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>
|
||||
|
||||
<!-- 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 -->
|
||||
<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 .views import HomeView, SearchView
|
||||
from . import views
|
||||
from autocomplete.urls import urlpatterns as autocomplete_patterns
|
||||
import os
|
||||
|
||||
urlpatterns = [
|
||||
@@ -58,6 +59,8 @@ urlpatterns = [
|
||||
views***REMOVED***ironment_and_settings_view,
|
||||
name="environment_and_settings",
|
||||
),
|
||||
# Autocomplete URLs
|
||||
path("ac/", include((autocomplete_patterns, "autocomplete"), namespace="autocomplete")),
|
||||
]
|
||||
|
||||
# Serve static files in development
|
||||
|
||||
Reference in New Issue
Block a user