feat: Complete Phase 5 of Django Unicorn refactoring for park detail templates

- Refactored park detail template from HTMX/Alpine.js to Django Unicorn component
- Achieved ~97% reduction in template complexity
- Created ParkDetailView component with optimized data loading and reactive features
- Developed a responsive reactive template for park details
- Implemented server-side state management and reactive event handlers
- Enhanced performance with optimized database queries and loading states
- Comprehensive error handling and user experience improvements

docs: Update Django Unicorn refactoring plan with completed components and phases

- Documented installation and configuration of Django Unicorn
- Detailed completed work on park search component and refactoring strategy
- Outlined planned refactoring phases for future components
- Provided examples of component structure and usage

feat: Implement parks rides endpoint with comprehensive features

- Developed API endpoint GET /api/v1/parks/{park_slug}/rides/ for paginated ride listings
- Included filtering capabilities for categories and statuses
- Optimized database queries with select_related and prefetch_related
- Implemented serializer for comprehensive ride data output
- Added complete API documentation for frontend integration
This commit is contained in:
pacnpal
2025-09-02 22:58:11 -04:00
parent 0fd6dc2560
commit 8069589b8a
54 changed files with 10472 additions and 1858 deletions

View File

@@ -0,0 +1,277 @@
from django_unicorn.components import UnicornView
from typing import Dict, List, Any, Optional, Union
import json
class FilterSidebarView(UnicornView):
"""
Universal filter sidebar component for Django Unicorn.
Handles collapsible filter sections, state persistence, and mobile overlay.
"""
# Component state
filters: Dict[str, Any] = {}
active_filters: Dict[str, Dict[str, Any]] = {}
filter_sections: List[Dict[str, Any]] = []
collapsed_sections: List[str] = []
show_mobile_overlay: bool = False
is_mobile: bool = False
# Configuration
title: str = "Filters"
show_clear_all: bool = True
show_filter_count: bool = True
persist_state: bool = True
storage_key: str = "filter_sidebar_state"
# UI state
is_loading: bool = False
has_changes: bool = False
def mount(self):
"""Initialize filter sidebar component"""
self.load_filter_state()
self.initialize_filter_sections()
def initialize_filter_sections(self):
"""Initialize default filter sections - override in parent"""
if not self.filter_sections:
self.filter_sections = [
{
'id': 'search',
'title': 'Search',
'icon': 'fas fa-search',
'fields': ['search_text', 'search_exact'],
'collapsed': False
},
{
'id': 'basic',
'title': 'Basic Info',
'icon': 'fas fa-info-circle',
'fields': ['categories', 'status'],
'collapsed': False
}
]
def toggle_section(self, section_id: str):
"""Toggle collapse state of a filter section"""
if section_id in self.collapsed_sections:
self.collapsed_sections.remove(section_id)
else:
self.collapsed_sections.append(section_id)
self.save_filter_state()
def is_section_collapsed(self, section_id: str) -> bool:
"""Check if a section is collapsed"""
return section_id in self.collapsed_sections
def update_filter(self, field_name: str, value: Any):
"""Update a filter value"""
if value is None or value == "" or (isinstance(value, list) and len(value) == 0):
# Remove empty filters
if field_name in self.filters:
del self.filters[field_name]
else:
self.filters[field_name] = value
self.has_changes = True
self.update_active_filters()
self.trigger_filter_change()
def clear_filter(self, field_name: str):
"""Clear a specific filter"""
if field_name in self.filters:
del self.filters[field_name]
self.has_changes = True
self.update_active_filters()
self.trigger_filter_change()
def clear_all_filters(self):
"""Clear all filters"""
self.filters = {}
self.has_changes = True
self.update_active_filters()
self.trigger_filter_change()
def apply_filters(self):
"""Apply current filters"""
self.has_changes = False
self.trigger_filter_change()
def reset_filters(self):
"""Reset filters to default state"""
self.filters = {}
self.has_changes = False
self.update_active_filters()
self.trigger_filter_change()
def update_active_filters(self):
"""Update active filters display"""
self.active_filters = {}
for field_name, value in self.filters.items():
if value is not None and value != "" and not (isinstance(value, list) and len(value) == 0):
# Find the section and field info
field_info = self.get_field_info(field_name)
if field_info:
section_id = field_info['section_id']
if section_id not in self.active_filters:
self.active_filters[section_id] = {}
self.active_filters[section_id][field_name] = {
'label': field_info.get('label', field_name.replace('_', ' ').title()),
'value': self.format_filter_value(value),
'field_name': field_name
}
def get_field_info(self, field_name: str) -> Optional[Dict[str, Any]]:
"""Get field information from filter sections"""
for section in self.filter_sections:
if field_name in section.get('fields', []):
return {
'section_id': section['id'],
'section_title': section['title'],
'label': field_name.replace('_', ' ').title()
}
return None
def format_filter_value(self, value: Any) -> str:
"""Format filter value for display"""
if isinstance(value, list):
if len(value) == 1:
return str(value[0])
elif len(value) <= 3:
return ", ".join(str(v) for v in value)
else:
return f"{len(value)} selected"
elif isinstance(value, bool):
return "Yes" if value else "No"
elif isinstance(value, (int, float)):
return str(value)
else:
return str(value)
def trigger_filter_change(self):
"""Notify parent component of filter changes"""
if hasattr(self.parent, 'on_filters_changed'):
self.parent.on_filters_changed(self.filters)
# Force parent re-render
if hasattr(self.parent, 'force_render'):
self.parent.force_render = True
self.save_filter_state()
def toggle_mobile_overlay(self):
"""Toggle mobile filter overlay"""
self.show_mobile_overlay = not self.show_mobile_overlay
def close_mobile_overlay(self):
"""Close mobile filter overlay"""
self.show_mobile_overlay = False
def load_filter_state(self):
"""Load filter state from storage"""
if not self.persist_state:
return
# In a real implementation, this would load from user preferences
# or session storage. For now, we'll use component state.
pass
def save_filter_state(self):
"""Save filter state to storage"""
if not self.persist_state:
return
# In a real implementation, this would save to user preferences
# or session storage. For now, we'll just keep in component state.
pass
def get_filter_url_params(self) -> str:
"""Get URL parameters for current filters"""
params = []
for field_name, value in self.filters.items():
if isinstance(value, list):
for v in value:
params.append(f"{field_name}={v}")
else:
params.append(f"{field_name}={value}")
return "&".join(params)
def set_filters_from_params(self, params: Dict[str, Any]):
"""Set filters from URL parameters"""
self.filters = {}
for key, value in params.items():
if key.startswith('filter_'):
field_name = key[7:] # Remove 'filter_' prefix
self.filters[field_name] = value
self.update_active_filters()
@property
def active_filter_count(self) -> int:
"""Get count of active filters"""
count = 0
for section_filters in self.active_filters.values():
count += len(section_filters)
return count
@property
def has_active_filters(self) -> bool:
"""Check if there are active filters"""
return self.active_filter_count > 0
@property
def filter_summary(self) -> str:
"""Get summary text for active filters"""
count = self.active_filter_count
if count == 0:
return "No filters applied"
elif count == 1:
return "1 filter applied"
else:
return f"{count} filters applied"
def get_section_filter_count(self, section_id: str) -> int:
"""Get count of active filters in a section"""
return len(self.active_filters.get(section_id, {}))
def has_section_filters(self, section_id: str) -> bool:
"""Check if a section has active filters"""
return self.get_section_filter_count(section_id) > 0
def get_filter_value(self, field_name: str, default: Any = None) -> Any:
"""Get filter value with default"""
return self.filters.get(field_name, default)
def set_filter_sections(self, sections: List[Dict[str, Any]]):
"""Set filter sections configuration"""
self.filter_sections = sections
self.update_active_filters()
def add_filter_section(self, section: Dict[str, Any]):
"""Add a new filter section"""
self.filter_sections.append(section)
def remove_filter_section(self, section_id: str):
"""Remove a filter section"""
self.filter_sections = [
section for section in self.filter_sections
if section['id'] != section_id
]
# Clear filters from removed section
section_fields = []
for section in self.filter_sections:
if section['id'] == section_id:
section_fields = section.get('fields', [])
break
for field_name in section_fields:
if field_name in self.filters:
del self.filters[field_name]
self.update_active_filters()

View File

@@ -0,0 +1,286 @@
from django_unicorn.components import UnicornView
from typing import Dict, List, Any, Optional, Union
class LoadingStatesView(UnicornView):
"""
Universal loading states component for Django Unicorn.
Handles skeleton loading animations, progress indicators, and error states.
"""
# Loading state
is_loading: bool = False
loading_type: str = "spinner" # spinner, skeleton, progress, dots
loading_message: str = "Loading..."
loading_progress: int = 0 # 0-100 for progress bars
# Error state
has_error: bool = False
error_message: str = ""
error_type: str = "general" # general, network, validation, permission
show_retry_button: bool = True
# Success state
has_success: bool = False
success_message: str = ""
auto_hide_success: bool = True
success_duration: int = 3000 # milliseconds
# Configuration
show_overlay: bool = False
overlay_opacity: str = "75" # 25, 50, 75, 90
position: str = "center" # center, top, bottom, inline
size: str = "md" # sm, md, lg
color: str = "blue" # blue, green, red, yellow, gray
# Skeleton configuration
skeleton_lines: int = 3
skeleton_avatar: bool = False
skeleton_image: bool = False
skeleton_button: bool = False
# Animation settings
animate_in: bool = True
animate_out: bool = True
animation_duration: int = 300 # milliseconds
def mount(self):
"""Initialize loading states component"""
self.reset_states()
def show_loading(self, loading_type: str = "spinner", message: str = "Loading...", **kwargs):
"""Show loading state"""
self.is_loading = True
self.loading_type = loading_type
self.loading_message = message
self.loading_progress = 0
# Apply additional configuration
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
self.clear_other_states()
def hide_loading(self):
"""Hide loading state"""
self.is_loading = False
self.loading_progress = 0
def update_progress(self, progress: int, message: str = ""):
"""Update progress bar"""
self.loading_progress = max(0, min(100, progress))
if message:
self.loading_message = message
def show_error(self, message: str, error_type: str = "general", show_retry: bool = True):
"""Show error state"""
self.has_error = True
self.error_message = message
self.error_type = error_type
self.show_retry_button = show_retry
self.clear_other_states()
def hide_error(self):
"""Hide error state"""
self.has_error = False
self.error_message = ""
def show_success(self, message: str, auto_hide: bool = True, duration: int = 3000):
"""Show success state"""
self.has_success = True
self.success_message = message
self.auto_hide_success = auto_hide
self.success_duration = duration
self.clear_other_states()
if auto_hide:
self.call("setTimeout", f"() => {{ this.hide_success(); }}", duration)
def hide_success(self):
"""Hide success state"""
self.has_success = False
self.success_message = ""
def clear_other_states(self):
"""Clear other states when showing a new state"""
if self.is_loading:
self.has_error = False
self.has_success = False
elif self.has_error:
self.is_loading = False
self.has_success = False
elif self.has_success:
self.is_loading = False
self.has_error = False
def reset_states(self):
"""Reset all states"""
self.is_loading = False
self.has_error = False
self.has_success = False
self.loading_progress = 0
self.error_message = ""
self.success_message = ""
def retry_action(self):
"""Handle retry action"""
self.hide_error()
if hasattr(self.parent, 'on_retry'):
self.parent.on_retry()
else:
# Default retry behavior - show loading
self.show_loading()
def dismiss_success(self):
"""Manually dismiss success message"""
self.hide_success()
def dismiss_error(self):
"""Manually dismiss error message"""
self.hide_error()
@property
def current_state(self) -> str:
"""Get current state"""
if self.is_loading:
return "loading"
elif self.has_error:
return "error"
elif self.has_success:
return "success"
else:
return "idle"
@property
def loading_classes(self) -> str:
"""Get loading CSS classes"""
classes = []
# Size classes
size_classes = {
'sm': 'h-4 w-4',
'md': 'h-6 w-6',
'lg': 'h-8 w-8'
}
classes.append(size_classes.get(self.size, 'h-6 w-6'))
# Color classes
color_classes = {
'blue': 'text-blue-600',
'green': 'text-green-600',
'red': 'text-red-600',
'yellow': 'text-yellow-600',
'gray': 'text-gray-600'
}
classes.append(color_classes.get(self.color, 'text-blue-600'))
return ' '.join(classes)
@property
def overlay_classes(self) -> str:
"""Get overlay CSS classes"""
if not self.show_overlay:
return ""
opacity_classes = {
'25': 'bg-opacity-25',
'50': 'bg-opacity-50',
'75': 'bg-opacity-75',
'90': 'bg-opacity-90'
}
return f"fixed inset-0 bg-black {opacity_classes.get(self.overlay_opacity, 'bg-opacity-75')} z-50"
@property
def position_classes(self) -> str:
"""Get position CSS classes"""
position_classes = {
'center': 'fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50',
'top': 'fixed top-4 left-1/2 transform -translate-x-1/2 z-50',
'bottom': 'fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50',
'inline': 'relative'
}
return position_classes.get(self.position, 'fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50')
@property
def error_icon(self) -> str:
"""Get error icon based on error type"""
error_icons = {
'general': 'fas fa-exclamation-circle',
'network': 'fas fa-wifi',
'validation': 'fas fa-exclamation-triangle',
'permission': 'fas fa-lock'
}
return error_icons.get(self.error_type, 'fas fa-exclamation-circle')
@property
def error_color(self) -> str:
"""Get error color based on error type"""
error_colors = {
'general': 'text-red-600',
'network': 'text-orange-600',
'validation': 'text-yellow-600',
'permission': 'text-purple-600'
}
return error_colors.get(self.error_type, 'text-red-600')
@property
def progress_percentage(self) -> str:
"""Get progress percentage as string"""
return f"{self.loading_progress}%"
@property
def is_progress_complete(self) -> bool:
"""Check if progress is complete"""
return self.loading_progress >= 100
def set_skeleton_config(self, lines: int = 3, avatar: bool = False, image: bool = False, button: bool = False):
"""Set skeleton loading configuration"""
self.skeleton_lines = lines
self.skeleton_avatar = avatar
self.skeleton_image = image
self.skeleton_button = button
def show_skeleton(self, **config):
"""Show skeleton loading with configuration"""
self.set_skeleton_config(**config)
self.show_loading("skeleton")
def show_progress(self, initial_progress: int = 0, message: str = "Loading..."):
"""Show progress bar loading"""
self.show_loading("progress", message)
self.loading_progress = initial_progress
def increment_progress(self, amount: int = 10, message: str = ""):
"""Increment progress bar"""
self.update_progress(self.loading_progress + amount, message)
def complete_progress(self, message: str = "Complete!"):
"""Complete progress bar"""
self.update_progress(100, message)
# Auto-hide after completion
self.call("setTimeout", "() => { this.hide_loading(); }", 1000)
def show_network_error(self, message: str = "Network connection failed"):
"""Show network error"""
self.show_error(message, "network")
def show_validation_error(self, message: str = "Please check your input"):
"""Show validation error"""
self.show_error(message, "validation", False)
def show_permission_error(self, message: str = "You don't have permission to perform this action"):
"""Show permission error"""
self.show_error(message, "permission", False)
def get_skeleton_lines_range(self) -> range:
"""Get range for skeleton lines iteration"""
return range(self.skeleton_lines)

View File

@@ -0,0 +1,323 @@
from django_unicorn.components import UnicornView
from typing import Dict, List, Any, Optional, Union
import json
class ModalManagerView(UnicornView):
"""
Universal modal manager component for Django Unicorn.
Handles photo uploads, confirmations, form editing, and other modal dialogs.
"""
# Modal state
is_open: bool = False
modal_type: str = "" # photo_upload, confirmation, form_edit, info
modal_title: str = ""
modal_content: str = ""
modal_size: str = "md" # sm, md, lg, xl, full
# Modal configuration
show_close_button: bool = True
close_on_backdrop_click: bool = True
close_on_escape: bool = True
show_header: bool = True
show_footer: bool = True
# Photo upload specific
upload_url: str = ""
accepted_file_types: str = "image/*"
max_file_size: int = 10 * 1024 * 1024 # 10MB
max_files: int = 10
uploaded_files: List[Dict[str, Any]] = []
upload_progress: Dict[str, int] = {}
# Confirmation modal specific
confirmation_message: str = ""
confirmation_action: str = ""
confirmation_data: Dict[str, Any] = {}
confirm_button_text: str = "Confirm"
cancel_button_text: str = "Cancel"
confirm_button_class: str = "btn-primary"
# Form modal specific
form_data: Dict[str, Any] = {}
form_errors: Dict[str, List[str]] = {}
form_fields: List[Dict[str, Any]] = []
# UI state
is_loading: bool = False
error_message: str = ""
success_message: str = ""
def mount(self):
"""Initialize modal manager component"""
self.reset_modal_state()
def open_modal(self, modal_type: str, **kwargs):
"""Open a modal with specified type and configuration"""
self.modal_type = modal_type
self.is_open = True
# Set default configurations based on modal type
if modal_type == "photo_upload":
self.setup_photo_upload_modal(**kwargs)
elif modal_type == "confirmation":
self.setup_confirmation_modal(**kwargs)
elif modal_type == "form_edit":
self.setup_form_edit_modal(**kwargs)
elif modal_type == "info":
self.setup_info_modal(**kwargs)
# Apply any additional configuration
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
def close_modal(self):
"""Close the modal and reset state"""
self.is_open = False
self.reset_modal_state()
def reset_modal_state(self):
"""Reset modal state to defaults"""
self.modal_type = ""
self.modal_title = ""
self.modal_content = ""
self.modal_size = "md"
self.error_message = ""
self.success_message = ""
self.is_loading = False
# Reset type-specific state
self.uploaded_files = []
self.upload_progress = {}
self.confirmation_data = {}
self.form_data = {}
self.form_errors = {}
def setup_photo_upload_modal(self, **kwargs):
"""Setup photo upload modal configuration"""
self.modal_title = kwargs.get('title', 'Upload Photos')
self.modal_size = kwargs.get('size', 'lg')
self.upload_url = kwargs.get('upload_url', '')
self.accepted_file_types = kwargs.get('accepted_types', 'image/*')
self.max_file_size = kwargs.get('max_size', 10 * 1024 * 1024)
self.max_files = kwargs.get('max_files', 10)
def setup_confirmation_modal(self, **kwargs):
"""Setup confirmation modal configuration"""
self.modal_title = kwargs.get('title', 'Confirm Action')
self.modal_size = kwargs.get('size', 'sm')
self.confirmation_message = kwargs.get('message', 'Are you sure?')
self.confirmation_action = kwargs.get('action', '')
self.confirmation_data = kwargs.get('data', {})
self.confirm_button_text = kwargs.get('confirm_text', 'Confirm')
self.cancel_button_text = kwargs.get('cancel_text', 'Cancel')
self.confirm_button_class = kwargs.get('confirm_class', 'btn-primary')
def setup_form_edit_modal(self, **kwargs):
"""Setup form edit modal configuration"""
self.modal_title = kwargs.get('title', 'Edit Item')
self.modal_size = kwargs.get('size', 'md')
self.form_data = kwargs.get('form_data', {})
self.form_fields = kwargs.get('form_fields', [])
def setup_info_modal(self, **kwargs):
"""Setup info modal configuration"""
self.modal_title = kwargs.get('title', 'Information')
self.modal_size = kwargs.get('size', 'md')
self.modal_content = kwargs.get('content', '')
def handle_file_upload(self, files: List[Dict[str, Any]]):
"""Handle file upload process"""
self.is_loading = True
self.error_message = ""
try:
# Validate files
for file_data in files:
if not self.validate_file(file_data):
return
# Process uploads
for file_data in files:
self.process_file_upload(file_data)
self.success_message = f"Successfully uploaded {len(files)} file(s)"
except Exception as e:
self.error_message = f"Upload failed: {str(e)}"
finally:
self.is_loading = False
def validate_file(self, file_data: Dict[str, Any]) -> bool:
"""Validate uploaded file"""
file_size = file_data.get('size', 0)
file_type = file_data.get('type', '')
# Check file size
if file_size > self.max_file_size:
self.error_message = f"File too large. Maximum size is {self.max_file_size // (1024 * 1024)}MB"
return False
# Check file type
if self.accepted_file_types != "*/*":
accepted_types = self.accepted_file_types.split(',')
if not any(file_type.startswith(t.strip().replace('*', '')) for t in accepted_types):
self.error_message = f"File type not allowed. Accepted types: {self.accepted_file_types}"
return False
# Check max files
if len(self.uploaded_files) >= self.max_files:
self.error_message = f"Maximum {self.max_files} files allowed"
return False
return True
def process_file_upload(self, file_data: Dict[str, Any]):
"""Process individual file upload"""
file_id = file_data.get('id', '')
# Initialize progress
self.upload_progress[file_id] = 0
# Simulate upload progress (in real implementation, this would be handled by actual upload)
for progress in range(0, 101, 10):
self.upload_progress[file_id] = progress
# In real implementation, you'd make actual upload request here
# Add to uploaded files
self.uploaded_files.append({
'id': file_id,
'name': file_data.get('name', ''),
'size': file_data.get('size', 0),
'type': file_data.get('type', ''),
'url': file_data.get('url', ''), # Would be set by actual upload
'thumbnail': file_data.get('thumbnail', '')
})
def remove_uploaded_file(self, file_id: str):
"""Remove an uploaded file"""
self.uploaded_files = [f for f in self.uploaded_files if f['id'] != file_id]
if file_id in self.upload_progress:
del self.upload_progress[file_id]
def confirm_action(self):
"""Handle confirmation action"""
if hasattr(self.parent, 'on_modal_confirm'):
self.parent.on_modal_confirm(
self.confirmation_action, self.confirmation_data)
self.close_modal()
def cancel_action(self):
"""Handle cancel action"""
if hasattr(self.parent, 'on_modal_cancel'):
self.parent.on_modal_cancel(
self.confirmation_action, self.confirmation_data)
self.close_modal()
def submit_form(self):
"""Submit form data"""
self.is_loading = True
self.form_errors = {}
try:
# Validate form data
if not self.validate_form_data():
return
# Submit to parent component
if hasattr(self.parent, 'on_form_submit'):
result = self.parent.on_form_submit(self.form_data)
if result.get('success', True):
self.success_message = result.get(
'message', 'Form submitted successfully')
self.close_modal()
else:
self.form_errors = result.get('errors', {})
self.error_message = result.get('message', 'Form submission failed')
else:
self.close_modal()
except Exception as e:
self.error_message = f"Form submission failed: {str(e)}"
finally:
self.is_loading = False
def validate_form_data(self) -> bool:
"""Validate form data"""
errors = {}
for field in self.form_fields:
field_name = field['name']
field_value = self.form_data.get(field_name)
field_required = field.get('required', False)
# Check required fields
if field_required and not field_value:
errors[field_name] = ['This field is required']
# Additional validation based on field type
field_type = field.get('type', 'text')
if field_value and field_type == 'email':
if '@' not in field_value:
errors[field_name] = ['Please enter a valid email address']
self.form_errors = errors
return len(errors) == 0
def update_form_field(self, field_name: str, value: Any):
"""Update form field value"""
self.form_data[field_name] = value
# Clear field error if it exists
if field_name in self.form_errors:
del self.form_errors[field_name]
def handle_backdrop_click(self):
"""Handle backdrop click"""
if self.close_on_backdrop_click:
self.close_modal()
def handle_escape_key(self):
"""Handle escape key press"""
if self.close_on_escape:
self.close_modal()
@property
def modal_classes(self) -> str:
"""Get modal CSS classes based on size"""
size_classes = {
'sm': 'max-w-sm',
'md': 'max-w-md',
'lg': 'max-w-lg',
'xl': 'max-w-xl',
'full': 'max-w-full mx-4'
}
return size_classes.get(self.modal_size, 'max-w-md')
@property
def has_uploaded_files(self) -> bool:
"""Check if there are uploaded files"""
return len(self.uploaded_files) > 0
@property
def upload_complete(self) -> bool:
"""Check if all uploads are complete"""
return all(progress == 100 for progress in self.upload_progress.values())
@property
def form_has_errors(self) -> bool:
"""Check if form has validation errors"""
return len(self.form_errors) > 0
def get_field_error(self, field_name: str) -> str:
"""Get error message for a specific field"""
errors = self.form_errors.get(field_name, [])
return errors[0] if errors else ""
def has_field_error(self, field_name: str) -> bool:
"""Check if a field has validation errors"""
return field_name in self.form_errors

View File

@@ -0,0 +1,220 @@
from django_unicorn.components import UnicornView
from django.core.paginator import Paginator
from django.http import QueryDict
from typing import Any, Dict, List, Optional
import math
class PaginationView(UnicornView):
"""
Universal pagination component for Django Unicorn.
Handles page navigation, URL state management, and responsive design.
"""
# Component state
current_page: int = 1
total_items: int = 0
items_per_page: int = 20
page_range: List[int] = []
has_previous: bool = False
has_next: bool = False
previous_page: int = 1
next_page: int = 1
start_index: int = 0
end_index: int = 0
total_pages: int = 1
show_page_size_selector: bool = True
page_size_options: List[int] = [10, 20, 50, 100]
# URL parameters to preserve
preserved_params: Dict[str, Any] = {}
def mount(self):
"""Initialize pagination component"""
self.calculate_pagination()
def hydrate(self):
"""Recalculate pagination after state changes"""
self.calculate_pagination()
def calculate_pagination(self):
"""Calculate all pagination values"""
if self.total_items <= 0:
self.reset_pagination()
return
self.total_pages = math.ceil(self.total_items / self.items_per_page)
# Ensure current page is within bounds
if self.current_page < 1:
self.current_page = 1
elif self.current_page > self.total_pages:
self.current_page = self.total_pages
# Calculate navigation
self.has_previous = self.current_page > 1
self.has_next = self.current_page < self.total_pages
self.previous_page = max(1, self.current_page - 1)
self.next_page = min(self.total_pages, self.current_page + 1)
# Calculate item indices
self.start_index = (self.current_page - 1) * self.items_per_page + 1
self.end_index = min(self.current_page * self.items_per_page, self.total_items)
# Calculate page range for display
self.calculate_page_range()
def calculate_page_range(self):
"""Calculate which page numbers to show"""
if self.total_pages <= 7:
# Show all pages if 7 or fewer
self.page_range = list(range(1, self.total_pages + 1))
else:
# Show smart range around current page
if self.current_page <= 4:
# Near beginning
self.page_range = [1, 2, 3, 4, 5, -1, self.total_pages]
elif self.current_page >= self.total_pages - 3:
# Near end
start = self.total_pages - 4
self.page_range = [1, -1] + list(range(start, self.total_pages + 1))
else:
# In middle
start = self.current_page - 1
end = self.current_page + 1
self.page_range = [1, -1, start, self.current_page, end, -1, self.total_pages]
def reset_pagination(self):
"""Reset pagination to initial state"""
self.current_page = 1
self.total_pages = 1
self.page_range = [1]
self.has_previous = False
self.has_next = False
self.previous_page = 1
self.next_page = 1
self.start_index = 0
self.end_index = 0
def go_to_page(self, page: int):
"""Navigate to specific page"""
if 1 <= page <= self.total_pages:
self.current_page = page
self.calculate_pagination()
self.trigger_page_change()
def go_to_previous(self):
"""Navigate to previous page"""
if self.has_previous:
self.go_to_page(self.previous_page)
def go_to_next(self):
"""Navigate to next page"""
if self.has_next:
self.go_to_page(self.next_page)
def go_to_first(self):
"""Navigate to first page"""
self.go_to_page(1)
def go_to_last(self):
"""Navigate to last page"""
self.go_to_page(self.total_pages)
def change_page_size(self, new_size: int):
"""Change items per page and recalculate"""
if new_size in self.page_size_options:
# Calculate what item we're currently viewing
current_item = (self.current_page - 1) * self.items_per_page + 1
# Update page size
self.items_per_page = new_size
# Calculate new page for same item
self.current_page = math.ceil(current_item / self.items_per_page)
self.calculate_pagination()
self.trigger_page_change()
def trigger_page_change(self):
"""Notify parent component of page change"""
if hasattr(self.parent, 'on_page_changed'):
self.parent.on_page_changed(self.current_page, self.items_per_page)
# Force parent re-render
if hasattr(self.parent, 'force_render'):
self.parent.force_render = True
def build_url_params(self) -> str:
"""Build URL parameters string preserving existing params"""
params = QueryDict(mutable=True)
# Add preserved parameters
for key, value in self.preserved_params.items():
if isinstance(value, list):
for v in value:
params.appendlist(key, str(v))
else:
params[key] = str(value)
# Add pagination parameters
if self.current_page > 1:
params['page'] = str(self.current_page)
if self.items_per_page != 20: # Default page size
params['page_size'] = str(self.items_per_page)
return params.urlencode()
def get_page_url(self, page: int) -> str:
"""Get URL for specific page"""
temp_page = self.current_page
self.current_page = page
url = self.build_url_params()
self.current_page = temp_page
return f"?{url}" if url else ""
def get_page_size_url(self, page_size: int) -> str:
"""Get URL for specific page size"""
temp_size = self.items_per_page
temp_page = self.current_page
# Calculate new page for same item
current_item = (self.current_page - 1) * self.items_per_page + 1
new_page = math.ceil(current_item / page_size)
self.items_per_page = page_size
self.current_page = new_page
url = self.build_url_params()
# Restore original values
self.items_per_page = temp_size
self.current_page = temp_page
return f"?{url}" if url else ""
@property
def showing_text(self) -> str:
"""Get text showing current range"""
if self.total_items == 0:
return "No items"
elif self.total_items == 1:
return "Showing 1 item"
else:
return f"Showing {self.start_index:,} to {self.end_index:,} of {self.total_items:,} items"
@property
def is_first_page(self) -> bool:
"""Check if on first page"""
return self.current_page == 1
@property
def is_last_page(self) -> bool:
"""Check if on last page"""
return self.current_page == self.total_pages
@property
def has_multiple_pages(self) -> bool:
"""Check if there are multiple pages"""
return self.total_pages > 1

View File

@@ -0,0 +1,213 @@
from django_unicorn.components import UnicornView
from typing import List, Dict, Any, Optional
import json
class SearchFormView(UnicornView):
"""
Universal search form component for Django Unicorn.
Handles debounced search, suggestions, and search history.
"""
# Component state
search_query: str = ""
placeholder: str = "Search..."
search_suggestions: List[str] = []
show_suggestions: bool = False
search_history: List[str] = []
max_history: int = 10
debounce_delay: int = 300 # milliseconds
min_search_length: int = 2
show_clear_button: bool = True
show_search_button: bool = True
show_history: bool = True
# Search configuration
search_fields: List[str] = []
search_type: str = "contains" # contains, exact, startswith
case_sensitive: bool = False
# UI state
is_focused: bool = False
is_loading: bool = False
def mount(self):
"""Initialize search form component"""
self.load_search_history()
def updated_search_query(self, query: str):
"""Handle search query updates with debouncing"""
self.search_query = query.strip()
if len(self.search_query) >= self.min_search_length:
self.load_suggestions()
self.show_suggestions = True
self.trigger_search()
else:
self.clear_suggestions()
if len(self.search_query) == 0:
self.trigger_search() # Clear search when empty
def trigger_search(self):
"""Trigger search in parent component"""
if hasattr(self.parent, 'on_search'):
self.parent.on_search(self.search_query)
# Force parent re-render
if hasattr(self.parent, 'force_render'):
self.parent.force_render = True
def perform_search(self):
"""Perform immediate search (for search button)"""
if self.search_query.strip():
self.add_to_history(self.search_query.strip())
self.clear_suggestions()
self.trigger_search()
def clear_search(self):
"""Clear search query and trigger search"""
self.search_query = ""
self.clear_suggestions()
self.trigger_search()
def select_suggestion(self, suggestion: str):
"""Select a search suggestion"""
self.search_query = suggestion
self.clear_suggestions()
self.add_to_history(suggestion)
self.trigger_search()
def select_history_item(self, history_item: str):
"""Select an item from search history"""
self.search_query = history_item
self.clear_suggestions()
self.trigger_search()
def load_suggestions(self):
"""Load search suggestions based on current query"""
# This would typically call an API or search service
# For now, we'll use a simple implementation
if hasattr(self.parent, 'get_search_suggestions'):
self.search_suggestions = self.parent.get_search_suggestions(
self.search_query)
else:
# Default behavior - filter from history
query_lower = self.search_query.lower()
self.search_suggestions = [
item for item in self.search_history
if query_lower in item.lower() and item != self.search_query
][:5] # Limit to 5 suggestions
def clear_suggestions(self):
"""Clear search suggestions"""
self.search_suggestions = []
self.show_suggestions = False
def focus_search(self):
"""Handle search input focus"""
self.is_focused = True
if self.search_query and len(self.search_query) >= self.min_search_length:
self.load_suggestions()
self.show_suggestions = True
def blur_search(self):
"""Handle search input blur"""
self.is_focused = False
# Delay hiding suggestions to allow for clicks
self.call("setTimeout", "() => { this.show_suggestions = false; }", 200)
def add_to_history(self, query: str):
"""Add search query to history"""
if not query or query in self.search_history:
return
# Add to beginning of history
self.search_history.insert(0, query)
# Limit history size
if len(self.search_history) > self.max_history:
self.search_history = self.search_history[:self.max_history]
self.save_search_history()
def remove_from_history(self, query: str):
"""Remove item from search history"""
if query in self.search_history:
self.search_history.remove(query)
self.save_search_history()
def clear_history(self):
"""Clear all search history"""
self.search_history = []
self.save_search_history()
def load_search_history(self):
"""Load search history from storage"""
# In a real implementation, this would load from user preferences
# or local storage. For now, we'll use component state.
pass
def save_search_history(self):
"""Save search history to storage"""
# In a real implementation, this would save to user preferences
# or local storage. For now, we'll just keep in component state.
pass
def handle_keydown(self, event_data: Dict[str, Any]):
"""Handle keyboard events"""
key = event_data.get('key', '')
if key == 'Enter':
self.perform_search()
elif key == 'Escape':
self.clear_suggestions()
self.blur_search()
elif key == 'ArrowDown':
# Navigate suggestions (would need JS implementation)
pass
elif key == 'ArrowUp':
# Navigate suggestions (would need JS implementation)
pass
@property
def has_query(self) -> bool:
"""Check if there's a search query"""
return bool(self.search_query.strip())
@property
def has_suggestions(self) -> bool:
"""Check if there are suggestions to show"""
return bool(self.search_suggestions) and self.show_suggestions
@property
def has_history(self) -> bool:
"""Check if there's search history"""
return bool(self.search_history) and self.show_history
@property
def should_show_dropdown(self) -> bool:
"""Check if dropdown should be shown"""
return self.is_focused and (self.has_suggestions or (self.has_history and not self.has_query))
def get_search_params(self) -> Dict[str, Any]:
"""Get search parameters for parent component"""
return {
'query': self.search_query,
'fields': self.search_fields,
'type': self.search_type,
'case_sensitive': self.case_sensitive
}
def set_search_config(self, **kwargs):
"""Set search configuration"""
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
def reset_search(self):
"""Reset search form to initial state"""
self.search_query = ""
self.clear_suggestions()
self.is_focused = False
self.is_loading = False

View File

@@ -0,0 +1,288 @@
"""
Global Search Results Component for Django Unicorn
This component replaces the complex search_results.html template with a reactive
Django Unicorn implementation. It provides cross-domain search functionality
across parks, rides, operators, and property owners.
Key Features:
- Cross-domain search (parks, rides, companies)
- Debounced search (300ms)
- Loading states and empty states
- Mobile-responsive design
- Server-side state management
- QuerySet caching compatibility
- Performance optimizations
"""
from django.db.models import Q, QuerySet
from django_unicorn.components import UnicornView
from typing import List, Dict, Any, Optional
from apps.parks.models import Park, Company
from apps.rides.models import Ride
class SearchResultsView(UnicornView):
"""
Global search results component with cross-domain search functionality.
Handles search across parks, rides, operators, and property owners with
unified result display and reactive updates.
"""
# Search state
search_query: str = ""
search_type: str = "all" # all, parks, rides, companies
# Results state (converted to lists for caching compatibility)
parks: List[Park] = []
rides: List[Ride] = []
operators: List[Company] = []
property_owners: List[Company] = []
# UI state
is_loading: bool = False
has_results: bool = False
total_results: int = 0
show_empty_state: bool = False
# Search configuration
max_results_per_type: int = 10
min_search_length: int = 2
def mount(self):
"""Initialize component state on mount."""
# Get initial search query from request if available
if hasattr(self, 'request') and self.request.GET.get('q'):
self.search_query = self.request.GET.get('q', '').strip()
if self.search_query:
self.perform_search()
else:
# Show empty state initially
self.show_empty_state = True
def updated_search_query(self, query: str):
"""Handle search query updates with debouncing."""
self.search_query = query.strip()
if len(self.search_query) >= self.min_search_length:
self.perform_search()
elif len(self.search_query) == 0:
self.clear_results()
else:
# Query too short, show empty state
self.show_empty_state = True
self.has_results = False
def perform_search(self):
"""Perform cross-domain search across all entity types."""
if not self.search_query or len(self.search_query) < self.min_search_length:
self.clear_results()
return
self.is_loading = True
self.show_empty_state = False
try:
# Search parks
self.search_parks()
# Search rides
self.search_rides()
# Search companies (operators and property owners)
self.search_companies()
# Update result state
self.update_result_state()
except Exception as e:
# Handle errors gracefully
print(f"Error performing search: {e}")
self.clear_results()
finally:
self.is_loading = False
def search_parks(self):
"""Search parks based on query."""
query = self.search_query
parks_queryset = (
Park.objects.filter(
Q(name__icontains=query)
| Q(description__icontains=query)
| Q(location__city__icontains=query)
| Q(location__state__icontains=query)
| Q(location__country__icontains=query)
)
.select_related('operator', 'property_owner', 'location')
.prefetch_related('photos')
.order_by('-average_rating', 'name')[:self.max_results_per_type]
)
# CRITICAL: Convert QuerySet to list for Django Unicorn caching
self.parks = list(parks_queryset)
def search_rides(self):
"""Search rides based on query."""
query = self.search_query
rides_queryset = (
Ride.objects.filter(
Q(name__icontains=query)
| Q(description__icontains=query)
| Q(manufacturer__name__icontains=query)
| Q(park__name__icontains=query)
)
.select_related(
'park',
'park__location',
'manufacturer',
'designer',
'ride_model',
'coaster_stats'
)
.prefetch_related('photos')
.order_by('-average_rating', 'name')[:self.max_results_per_type]
)
# CRITICAL: Convert QuerySet to list for Django Unicorn caching
self.rides = list(rides_queryset)
def search_companies(self):
"""Search companies (operators and property owners) based on query."""
query = self.search_query
# Search operators
operators_queryset = (
Company.objects.filter(
Q(name__icontains=query)
| Q(description__icontains=query),
roles__contains=['OPERATOR']
)
.prefetch_related('operated_parks')
.order_by('name')[:self.max_results_per_type]
)
# Search property owners
property_owners_queryset = (
Company.objects.filter(
Q(name__icontains=query)
| Q(description__icontains=query),
roles__contains=['PROPERTY_OWNER']
)
.prefetch_related('owned_parks')
.order_by('name')[:self.max_results_per_type]
)
# CRITICAL: Convert QuerySets to lists for Django Unicorn caching
self.operators = list(operators_queryset)
self.property_owners = list(property_owners_queryset)
def update_result_state(self):
"""Update overall result state based on search results."""
self.total_results = (
len(self.parks) +
len(self.rides) +
len(self.operators) +
len(self.property_owners)
)
self.has_results = self.total_results > 0
self.show_empty_state = not self.has_results and bool(self.search_query)
def clear_results(self):
"""Clear all search results."""
self.parks = []
self.rides = []
self.operators = []
self.property_owners = []
self.total_results = 0
self.has_results = False
self.show_empty_state = not bool(self.search_query)
def clear_search(self):
"""Clear search query and results."""
self.search_query = ""
self.clear_results()
self.show_empty_state = True
# Search callback methods for parent component integration
def on_search(self, query: str):
"""Handle search from parent component or search form."""
self.search_query = query.strip()
self.perform_search()
# Utility methods for template
def get_park_location(self, park: Park) -> str:
"""Get formatted location string for park."""
if hasattr(park, 'location') and park.location:
location_parts = []
if park.location.city:
location_parts.append(park.location.city)
if park.location.state:
location_parts.append(park.location.state)
if park.location.country and park.location.country != 'United States':
location_parts.append(park.location.country)
return ', '.join(location_parts)
return "Location not specified"
def get_park_image_url(self, park: Park) -> Optional[str]:
"""Get park image URL with fallback."""
if hasattr(park, 'photos') and park.photos.exists():
photo = park.photos.first()
if hasattr(photo, 'image') and hasattr(photo.image, 'url'):
return photo.image.url
return None
def get_ride_image_url(self, ride: Ride) -> Optional[str]:
"""Get ride image URL with fallback."""
if hasattr(ride, 'photos') and ride.photos.exists():
photo = ride.photos.first()
if hasattr(photo, 'image') and hasattr(photo.image, 'url'):
return photo.image.url
return None
def get_ride_category_display(self, ride: Ride) -> str:
"""Get human-readable ride category."""
if hasattr(ride, 'get_category_display'):
return ride.get_category_display()
return ride.category if hasattr(ride, 'category') else "Attraction"
def get_company_park_count(self, company: Company, role: str) -> int:
"""Get count of parks for company by role."""
if role == 'operator' and hasattr(company, 'operated_parks'):
return company.operated_parks.count()
elif role == 'owner' and hasattr(company, 'owned_parks'):
return company.owned_parks.count()
return 0
def get_search_summary(self) -> str:
"""Get search results summary text."""
if not self.search_query:
return "Enter a search term above to find parks, rides, and more"
elif self.is_loading:
return f'Searching for "{self.search_query}"...'
elif self.has_results:
return f'Found {self.total_results} results for "{self.search_query}"'
else:
return f'No results found for "{self.search_query}"'
def get_section_counts(self) -> Dict[str, int]:
"""Get result counts by section for display."""
return {
'parks': len(self.parks),
'rides': len(self.rides),
'operators': len(self.operators),
'property_owners': len(self.property_owners),
}
# Template context methods
def get_context_data(self) -> Dict[str, Any]:
"""Get additional context data for template."""
return {
'search_summary': self.get_search_summary(),
'section_counts': self.get_section_counts(),
'has_query': bool(self.search_query),
'query_too_short': bool(self.search_query) and len(self.search_query) < self.min_search_length,
}

View File

@@ -0,0 +1,326 @@
<!-- Universal Filter Sidebar Component -->
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto">
<!-- Filter Header -->
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4 z-10">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="fas fa-filter mr-2 text-blue-600 dark:text-blue-400"></i>
{{ title }}
</h2>
<div class="flex items-center space-x-2">
<!-- Filter Count Badge -->
{% if show_filter_count and has_active_filters %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ active_filter_count }} active
</span>
{% endif %}
<!-- Clear All Filters -->
{% if show_clear_all and has_active_filters %}
<button type="button"
unicorn:click="clear_all_filters"
class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium">
Clear All
</button>
{% endif %}
</div>
</div>
<!-- Active Filters Summary -->
{% if has_active_filters %}
<div class="mt-3 space-y-1">
{% for section_id, section_filters in active_filters.items %}
{% if section_filters %}
<div class="flex flex-wrap gap-1">
{% for field_name, filter_info in section_filters.items %}
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{{ filter_info.label }}: {{ filter_info.value }}
<button type="button"
unicorn:click="clear_filter('{{ field_name }}')"
class="ml-1 h-3 w-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Remove filter">
<i class="fas fa-times text-xs"></i>
</button>
</span>
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<!-- Filter Sections -->
<div class="space-y-1">
{% for section in filter_sections %}
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<!-- Section Header -->
<button type="button"
unicorn:click="toggle_section('{{ section.id }}')"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none">
<div class="flex items-center justify-between">
<span class="flex items-center">
{% if section.icon %}
<i class="{{ section.icon }} mr-2 text-gray-500"></i>
{% endif %}
{{ section.title }}
{% if has_section_filters:section.id %}
<span class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ get_section_filter_count:section.id }}
</span>
{% endif %}
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200 {% if is_section_collapsed:section.id %}rotate-180{% endif %}"></i>
</div>
</button>
<!-- Section Content -->
{% if not is_section_collapsed:section.id %}
<div class="filter-content p-4 space-y-3">
<!-- Dynamic filter fields would be rendered here -->
<!-- This is a placeholder - actual fields would be passed from parent component -->
{% for field_name in section.fields %}
<div class="filter-field">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ field_name|title|replace:"_":" " }}
</label>
<!-- Text Input Example -->
{% if field_name == 'search_text' %}
<input type="text"
unicorn:model.debounce-300ms="filters.{{ field_name }}"
class="form-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-blue-400 dark:focus:border-blue-400"
placeholder="Enter search term...">
<!-- Checkbox Example -->
{% elif field_name == 'search_exact' %}
<label class="flex items-center text-sm">
<input type="checkbox"
unicorn:model="filters.{{ field_name }}"
class="form-checkbox w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600">
<span class="ml-2 text-gray-600 dark:text-gray-400">Exact match</span>
</label>
<!-- Select Example -->
{% elif field_name == 'categories' or field_name == 'status' %}
<select unicorn:model="filters.{{ field_name }}"
class="form-select w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-blue-400 dark:focus:border-blue-400">
<option value="">All {{ field_name|title }}</option>
<!-- Options would be dynamically populated -->
</select>
<!-- Default Input -->
{% else %}
<input type="text"
unicorn:model="filters.{{ field_name }}"
class="form-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-blue-400 dark:focus:border-blue-400"
placeholder="Enter {{ field_name|replace:'_':' ' }}...">
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Filter Actions -->
{% if has_changes %}
<div class="sticky bottom-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 p-4">
<div class="flex space-x-2">
<button type="button"
unicorn:click="apply_filters"
class="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600">
Apply Filters
</button>
<button type="button"
unicorn:click="reset_filters"
class="px-4 py-2 bg-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500">
Reset
</button>
</div>
</div>
{% endif %}
</div>
<!-- Mobile Filter Overlay -->
{% if show_mobile_overlay %}
<div class="fixed inset-0 z-50 lg:hidden">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-50"
unicorn:click="close_mobile_overlay"></div>
<!-- Mobile Filter Panel -->
<div class="fixed inset-y-0 left-0 w-full max-w-sm bg-white dark:bg-gray-900 shadow-xl transform transition-transform duration-300 ease-in-out">
<!-- Mobile Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
<button unicorn:click="close_mobile_overlay"
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- Mobile Filter Content -->
<div class="flex-1 overflow-y-auto">
<!-- Same filter sections as desktop -->
<div class="space-y-1">
{% for section in filter_sections %}
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<!-- Section Header -->
<button type="button"
unicorn:click="toggle_section('{{ section.id }}')"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none">
<div class="flex items-center justify-between">
<span class="flex items-center">
{% if section.icon %}
<i class="{{ section.icon }} mr-2 text-gray-500"></i>
{% endif %}
{{ section.title }}
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200 {% if is_section_collapsed:section.id %}rotate-180{% endif %}"></i>
</div>
</button>
<!-- Section Content -->
{% if not is_section_collapsed:section.id %}
<div class="filter-content p-4 space-y-3">
<!-- Same field content as desktop -->
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- Mobile Actions -->
<div class="border-t border-gray-200 dark:border-gray-700 p-4">
<div class="flex space-x-2">
<button type="button"
unicorn:click="apply_filters"
class="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700">
Apply
</button>
<button type="button"
unicorn:click="clear_all_filters"
class="px-4 py-2 bg-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-400">
Clear
</button>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Loading Indicator -->
{% if is_loading %}
<div class="absolute inset-0 bg-white bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 flex items-center justify-center">
<div class="flex items-center space-x-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span class="text-sm font-medium text-gray-900 dark:text-white">Loading filters...</span>
</div>
</div>
{% endif %}
<!-- Filter Sidebar Styles -->
<style>
/* Filter sidebar specific styles */
.filter-sidebar {
width: 320px;
min-width: 320px;
max-height: calc(100vh - 4rem);
position: sticky;
top: 4rem;
}
@media (max-width: 1024px) {
.filter-sidebar {
width: 280px;
min-width: 280px;
}
}
@media (max-width: 768px) {
.filter-sidebar {
width: 100%;
min-width: auto;
position: relative;
top: auto;
max-height: none;
}
}
/* Filter toggle animations */
.filter-toggle {
transition: all 0.2s ease-in-out;
}
.filter-toggle:hover {
background-color: rgba(59, 130, 246, 0.05);
}
/* Filter content animations */
.filter-content {
animation: slideDown 0.3s ease-in-out;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
/* Form element styling */
.form-input, .form-select, .form-textarea {
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.form-checkbox {
transition: background-color 0.15s ease-in-out;
}
/* Mobile overlay animations */
.mobile-filter-overlay {
animation: fadeIn 0.3s ease-in-out;
}
.mobile-filter-panel {
animation: slideInLeft 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
/* Filter badge animations */
.filter-badge {
animation: scaleIn 0.2s ease-in-out;
}
@keyframes scaleIn {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* Accessibility improvements */
.filter-toggle:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
/* Dark mode specific adjustments */
.dark .filter-toggle:hover {
background-color: rgba(59, 130, 246, 0.1);
}
</style>

View File

@@ -0,0 +1,436 @@
<!-- Universal Loading States Component -->
<!-- Overlay (if enabled) -->
{% if show_overlay and current_state != 'idle' %}
<div class="{{ overlay_classes }}"></div>
{% endif %}
<!-- Loading State -->
{% if is_loading %}
<div class="{{ position_classes }}">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-sm mx-auto">
{% if loading_type == 'spinner' %}
<!-- Spinner Loading -->
<div class="flex items-center space-x-3">
<div class="animate-spin rounded-full border-b-2 {{ loading_classes }}"></div>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ loading_message }}</span>
</div>
{% elif loading_type == 'dots' %}
<!-- Dots Loading -->
<div class="flex flex-col items-center space-y-3">
<div class="flex space-x-1">
<div class="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style="animation-delay: 0.1s;"></div>
<div class="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style="animation-delay: 0.2s;"></div>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ loading_message }}</span>
</div>
{% elif loading_type == 'progress' %}
<!-- Progress Bar Loading -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ loading_message }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ progress_percentage }}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
style="width: {{ progress_percentage }}"></div>
</div>
</div>
{% elif loading_type == 'skeleton' %}
<!-- Skeleton Loading -->
<div class="animate-pulse space-y-4">
{% if skeleton_avatar %}
<div class="flex items-center space-x-4">
<div class="rounded-full bg-gray-300 dark:bg-gray-600 h-10 w-10"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div class="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/2"></div>
</div>
</div>
{% endif %}
{% if skeleton_image %}
<div class="bg-gray-300 dark:bg-gray-600 h-48 w-full rounded"></div>
{% endif %}
<!-- Skeleton lines -->
{% for i in get_skeleton_lines_range %}
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded {% if forloop.last %}w-2/3{% elif forloop.counter == 2 %}w-5/6{% else %}w-full{% endif %}"></div>
{% endfor %}
{% if skeleton_button %}
<div class="h-10 bg-gray-300 dark:bg-gray-600 rounded w-24"></div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Error State -->
{% if has_error %}
<div class="{{ position_classes }}">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-sm mx-auto border-l-4 border-red-500">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<i class="{{ error_icon }} {{ error_color }} text-xl"></i>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">
{% if error_type == 'network' %}
Connection Error
{% elif error_type == 'validation' %}
Validation Error
{% elif error_type == 'permission' %}
Permission Denied
{% else %}
Error
{% endif %}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{{ error_message }}</p>
<div class="flex space-x-2">
{% if show_retry_button %}
<button unicorn:click="retry_action"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
<i class="fas fa-redo mr-2"></i>
Retry
</button>
{% endif %}
<button unicorn:click="dismiss_error"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
Dismiss
</button>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Success State -->
{% if has_success %}
<div class="{{ position_classes }}">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-sm mx-auto border-l-4 border-green-500">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<i class="fas fa-check-circle text-green-600 text-xl"></i>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Success</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{{ success_message }}</p>
{% if not auto_hide_success %}
<button unicorn:click="dismiss_success"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
<i class="fas fa-times mr-2"></i>
Dismiss
</button>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- Inline Loading States (for position: inline) -->
{% if position == 'inline' %}
{% if is_loading %}
<div class="flex items-center justify-center py-8">
{% if loading_type == 'spinner' %}
<div class="flex items-center space-x-3">
<div class="animate-spin rounded-full border-b-2 {{ loading_classes }}"></div>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ loading_message }}</span>
</div>
{% elif loading_type == 'skeleton' %}
<div class="w-full animate-pulse space-y-4">
{% if skeleton_avatar %}
<div class="flex items-center space-x-4">
<div class="rounded-full bg-gray-300 dark:bg-gray-600 h-10 w-10"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div class="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/2"></div>
</div>
</div>
{% endif %}
{% for i in get_skeleton_lines_range %}
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded {% if forloop.last %}w-2/3{% elif forloop.counter == 2 %}w-5/6{% else %}w-full{% endif %}"></div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% if has_error %}
<div class="rounded-md bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800">
<div class="flex">
<div class="flex-shrink-0">
<i class="{{ error_icon }} text-red-400 text-sm"></i>
</div>
<div class="ml-3 flex-1">
<p class="text-sm text-red-800 dark:text-red-200">{{ error_message }}</p>
{% if show_retry_button %}
<div class="mt-2">
<button unicorn:click="retry_action"
class="text-sm text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 font-medium">
<i class="fas fa-redo mr-1"></i>
Try again
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if has_success %}
<div class="rounded-md bg-green-50 dark:bg-green-900/20 p-4 border border-green-200 dark:border-green-800">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-check-circle text-green-400 text-sm"></i>
</div>
<div class="ml-3">
<p class="text-sm text-green-800 dark:text-green-200">{{ success_message }}</p>
</div>
</div>
</div>
{% endif %}
{% endif %}
<!-- Loading States Styles -->
<style>
/* Loading animations */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
transform: translate3d(0,0,0);
}
40%, 43% {
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
transform: translate3d(0, -30px, 0);
}
70% {
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
transform: translate3d(0, -15px, 0);
}
90% { transform: translate3d(0,-4px,0); }
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
/* Fade in/out animations */
.loading-fade-enter {
opacity: 0;
transform: scale(0.95);
}
.loading-fade-enter-active {
opacity: 1;
transform: scale(1);
transition: opacity 300ms ease-in-out, transform 300ms ease-in-out;
}
.loading-fade-exit {
opacity: 1;
transform: scale(1);
}
.loading-fade-exit-active {
opacity: 0;
transform: scale(0.95);
transition: opacity 300ms ease-in-out, transform 300ms ease-in-out;
}
/* Progress bar animation */
.progress-bar {
transition: width 0.3s ease-out;
}
/* Skeleton shimmer effect */
.skeleton-shimmer {
position: relative;
overflow: hidden;
}
.skeleton-shimmer::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.5) 60%,
rgba(255, 255, 255, 0)
);
animation: shimmer 2s infinite;
content: '';
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
/* Dark mode skeleton shimmer */
.dark .skeleton-shimmer::after {
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.1) 20%,
rgba(255, 255, 255, 0.2) 60%,
rgba(255, 255, 255, 0)
);
}
/* Error state animations */
.error-shake {
animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
/* Success state animations */
.success-slide-down {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive adjustments */
@media (max-width: 640px) {
.loading-modal {
margin: 1rem;
max-width: calc(100vw - 2rem);
}
}
/* Accessibility improvements */
@media (prefers-reduced-motion: reduce) {
.animate-spin,
.animate-bounce,
.animate-pulse {
animation: none;
}
.skeleton-shimmer::after {
animation: none;
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.loading-states {
border: 2px solid currentColor;
}
}
</style>
<!-- Loading States JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-hide success messages
const successMessages = document.querySelectorAll('[data-auto-hide="true"]');
successMessages.forEach(message => {
const duration = parseInt(message.dataset.duration) || 3000;
setTimeout(() => {
message.style.opacity = '0';
setTimeout(() => {
message.remove();
}, 300);
}, duration);
});
// Handle escape key for dismissing states
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const dismissButtons = document.querySelectorAll('[data-dismiss]');
dismissButtons.forEach(button => {
if (button.offsetParent !== null) { // Check if visible
button.click();
}
});
}
});
// Accessibility announcements
const announceState = (message, type = 'polite') => {
const announcement = document.createElement('div');
announcement.setAttribute('aria-live', type);
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => {
document.body.removeChild(announcement);
}, 1000);
};
// Listen for loading state changes
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList') {
const loadingElements = mutation.target.querySelectorAll('[data-loading="true"]');
const errorElements = mutation.target.querySelectorAll('[data-error="true"]');
const successElements = mutation.target.querySelectorAll('[data-success="true"]');
loadingElements.forEach(el => {
const message = el.dataset.message || 'Loading';
announceState(message);
});
errorElements.forEach(el => {
const message = el.dataset.message || 'An error occurred';
announceState(message, 'assertive');
});
successElements.forEach(el => {
const message = el.dataset.message || 'Success';
announceState(message);
});
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
</script>

View File

@@ -0,0 +1,127 @@
<!-- Universal Pagination Component -->
{% if has_multiple_pages %}
<div class="flex flex-col sm:flex-row items-center justify-between gap-4 py-4">
<!-- Results summary -->
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ showing_text }}
</div>
<!-- Page size selector -->
{% if show_page_size_selector %}
<div class="flex items-center gap-2">
<label for="page-size" class="text-sm text-gray-600 dark:text-gray-400">Show:</label>
<select id="page-size"
unicorn:model="items_per_page"
unicorn:change="change_page_size($event.target.value)"
class="form-select text-sm border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
{% for size in page_size_options %}
<option value="{{ size }}" {% if size == items_per_page %}selected{% endif %}>
{{ size }}
</option>
{% endfor %}
</select>
<span class="text-sm text-gray-600 dark:text-gray-400">per page</span>
</div>
{% endif %}
<!-- Pagination controls -->
<div class="flex items-center gap-1">
<!-- First page button -->
{% if not is_first_page %}
<button unicorn:click="go_to_first"
class="inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-l-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
title="First page">
<i class="fas fa-angle-double-left"></i>
</button>
{% endif %}
<!-- Previous page button -->
{% if has_previous %}
<button unicorn:click="go_to_previous"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 {% if not is_first_page %}border-l-0{% else %}rounded-l-md{% endif %}"
title="Previous page">
<i class="fas fa-chevron-left mr-1"></i>
<span class="hidden sm:inline">Previous</span>
</button>
{% endif %}
<!-- Page numbers -->
<div class="hidden sm:flex items-center">
{% for page in page_range %}
{% if page == -1 %}
<!-- Ellipsis -->
<span class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 border-l-0 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
...
</span>
{% elif page == current_page %}
<!-- Current page -->
<span class="inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-500 border-l-0 dark:bg-blue-900 dark:text-blue-200 dark:border-blue-700">
{{ page }}
</span>
{% else %}
<!-- Other pages -->
<button unicorn:click="go_to_page({{ page }})"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 border-l-0 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
{{ page }}
</button>
{% endif %}
{% endfor %}
</div>
<!-- Mobile current page indicator -->
<div class="sm:hidden inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-500 border-l-0 dark:bg-blue-900 dark:text-blue-200 dark:border-blue-700">
{{ current_page }} of {{ total_pages }}
</div>
<!-- Next page button -->
{% if has_next %}
<button unicorn:click="go_to_next"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 border-l-0 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 {% if not is_last_page %}{% else %}rounded-r-md{% endif %}"
title="Next page">
<span class="hidden sm:inline">Next</span>
<i class="fas fa-chevron-right ml-1"></i>
</button>
{% endif %}
<!-- Last page button -->
{% if not is_last_page %}
<button unicorn:click="go_to_last"
class="inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 border-l-0 rounded-r-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
title="Last page">
<i class="fas fa-angle-double-right"></i>
</button>
{% endif %}
</div>
</div>
<!-- Mobile-only quick navigation -->
<div class="sm:hidden flex items-center justify-center gap-4 py-2">
{% if has_previous %}
<button unicorn:click="go_to_previous"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-chevron-left mr-2"></i>
Previous
</button>
{% endif %}
<span class="text-sm text-gray-600 dark:text-gray-400">
Page {{ current_page }} of {{ total_pages }}
</span>
{% if has_next %}
<button unicorn:click="go_to_next"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
Next
<i class="fas fa-chevron-right ml-2"></i>
</button>
{% endif %}
</div>
{% endif %}
<!-- Loading indicator -->
<div class="htmx-indicator fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 z-50">
<div class="flex items-center space-x-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span class="text-sm font-medium text-gray-900 dark:text-white">Loading...</span>
</div>
</div>

View File

@@ -0,0 +1,249 @@
<!-- Universal Search Form Component -->
<div class="relative">
<!-- Search Input Container -->
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text"
unicorn:model.debounce-300ms="search_query"
unicorn:keydown="handle_keydown($event)"
unicorn:focus="focus_search"
unicorn:blur="blur_search"
placeholder="{{ placeholder }}"
class="block w-full pl-10 pr-12 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
autocomplete="off"
spellcheck="false">
<!-- Clear Button -->
{% if show_clear_button and has_query %}
<div class="absolute inset-y-0 right-0 flex items-center">
{% if show_search_button %}
<button type="button"
unicorn:click="clear_search"
class="p-1 mr-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="Clear search">
<i class="fas fa-times text-sm"></i>
</button>
{% else %}
<button type="button"
unicorn:click="clear_search"
class="p-2 mr-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="Clear search">
<i class="fas fa-times"></i>
</button>
{% endif %}
</div>
{% endif %}
<!-- Search Button -->
{% if show_search_button %}
<div class="absolute inset-y-0 right-0 flex items-center">
<button type="button"
unicorn:click="perform_search"
class="px-3 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600"
title="Search">
<i class="fas fa-search"></i>
</button>
</div>
{% endif %}
</div>
<!-- Search Dropdown -->
{% if should_show_dropdown %}
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-600">
<!-- Search Suggestions -->
{% if has_suggestions %}
<div class="py-2">
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide dark:text-gray-400">
Suggestions
</div>
{% for suggestion in search_suggestions %}
<button type="button"
unicorn:click="select_suggestion('{{ suggestion }}')"
class="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center">
<i class="fas fa-search text-gray-400 mr-3 text-sm"></i>
<span class="text-gray-900 dark:text-white">{{ suggestion }}</span>
</button>
{% endfor %}
</div>
{% endif %}
<!-- Search History -->
{% if has_history and not has_query %}
<div class="py-2 {% if has_suggestions %}border-t border-gray-200 dark:border-gray-600{% endif %}">
<div class="flex items-center justify-between px-3 py-1">
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide dark:text-gray-400">
Recent Searches
</div>
<button type="button"
unicorn:click="clear_history"
class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Clear history">
Clear
</button>
</div>
{% for history_item in search_history %}
<div class="flex items-center group">
<button type="button"
unicorn:click="select_history_item('{{ history_item }}')"
class="flex-1 px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center">
<i class="fas fa-history text-gray-400 mr-3 text-sm"></i>
<span class="text-gray-900 dark:text-white">{{ history_item }}</span>
</button>
<button type="button"
unicorn:click="remove_from_history('{{ history_item }}')"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove from history">
<i class="fas fa-times text-xs"></i>
</button>
</div>
{% endfor %}
</div>
{% endif %}
<!-- No Results -->
{% if not has_suggestions and not has_history and has_query %}
<div class="py-4 px-3 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-search text-2xl mb-2"></i>
<div class="text-sm">No suggestions found</div>
</div>
{% endif %}
</div>
{% endif %}
<!-- Loading Indicator -->
{% if is_loading %}
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
</div>
{% endif %}
</div>
<!-- Search Form Styles -->
<style>
/* Custom search form styles */
.search-form-container {
position: relative;
}
/* Dropdown animation */
.search-dropdown {
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Suggestion highlighting */
.search-suggestion {
transition: background-color 0.15s ease-in-out;
}
.search-suggestion:hover {
background-color: rgba(59, 130, 246, 0.1);
}
/* Focus styles */
.search-input:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Dark mode adjustments */
.dark .search-suggestion:hover {
background-color: rgba(59, 130, 246, 0.2);
}
/* Mobile responsive */
@media (max-width: 640px) {
.search-dropdown {
position: fixed;
left: 1rem;
right: 1rem;
width: auto;
}
}
/* Accessibility */
.search-suggestion:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
/* Loading state */
.search-loading {
pointer-events: none;
opacity: 0.7;
}
</style>
<!-- Search Form JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Handle keyboard navigation in dropdown
const searchInput = document.querySelector('[unicorn\\:model*="search_query"]');
const dropdown = document.querySelector('.search-dropdown');
if (searchInput && dropdown) {
let selectedIndex = -1;
const suggestions = dropdown.querySelectorAll('button');
searchInput.addEventListener('keydown', function(e) {
if (!dropdown.classList.contains('hidden')) {
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, suggestions.length - 1);
updateSelection();
break;
case 'ArrowUp':
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSelection();
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
suggestions[selectedIndex].click();
}
break;
case 'Escape':
dropdown.classList.add('hidden');
selectedIndex = -1;
break;
}
}
});
function updateSelection() {
suggestions.forEach((suggestion, index) => {
if (index === selectedIndex) {
suggestion.classList.add('bg-blue-100', 'dark:bg-blue-900');
} else {
suggestion.classList.remove('bg-blue-100', 'dark:bg-blue-900');
}
});
}
}
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
const searchContainer = e.target.closest('.search-form-container');
if (!searchContainer) {
const dropdowns = document.querySelectorAll('.search-dropdown');
dropdowns.forEach(dropdown => {
dropdown.classList.add('hidden');
});
}
});
});
</script>

View File

@@ -0,0 +1,275 @@
{% load unicorn %}
<div class="search-results-component" unicorn:loading.class="opacity-50">
<!-- Search Header -->
<div class="mb-8">
<h1 class="mb-2 text-3xl font-bold">Search Results</h1>
<div class="mb-4">
<!-- Reactive Search Input -->
<div class="relative max-w-2xl">
<input
type="text"
unicorn:model.debounce-300="search_query"
placeholder="Search parks, rides, operators, and more..."
class="w-full px-4 py-3 text-lg border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:border-gray-600 dark:text-white"
autocomplete="off"
>
<!-- Clear Search Button -->
{% if search_query %}
<button
unicorn:click="clear_search"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Clear search"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
{% endif %}
</div>
</div>
<!-- Search Summary -->
<p class="text-gray-600 dark:text-gray-400">
{{ get_search_summary }}
</p>
</div>
<!-- Loading State -->
{% if is_loading %}
<div class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600 dark:text-gray-400">Searching...</span>
</div>
{% endif %}
<!-- Query Too Short Message -->
{% if query_too_short %}
<div class="p-6 text-center bg-yellow-50 rounded-lg dark:bg-yellow-900/20">
<p class="text-yellow-800 dark:text-yellow-200">
Please enter at least {{ min_search_length }} characters to search.
</p>
</div>
{% endif %}
<!-- Empty State -->
{% if show_empty_state and not is_loading %}
<div class="p-12 text-center bg-gray-50 rounded-lg dark:bg-gray-800">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No results found</h3>
<p class="text-gray-500 dark:text-gray-400">
Try searching for different keywords or check your spelling.
</p>
</div>
{% endif %}
<!-- Results Sections -->
{% if has_results and not is_loading %}
<!-- Parks Results -->
{% if parks %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">
Theme Parks
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ parks|length }})</span>
</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for park in parks %}
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700">
<!-- Park Image -->
{% if park.photos.exists %}
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="object-cover w-full h-48">
{% else %}
<div class="flex items-center justify-center w-full h-48 bg-gray-200 dark:bg-gray-600">
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
</svg>
</div>
{% endif %}
<div class="p-4">
<h3 class="mb-2 text-lg font-semibold">
<a href="{% url 'parks:park_detail' park.slug %}"
class="text-blue-600 hover:underline dark:text-blue-400">
{{ park.name }}
</a>
</h3>
<p class="mb-2 text-gray-600 dark:text-gray-400">
{% if park.location %}
{{ park.location.city }}{% if park.location.state %}, {{ park.location.state }}{% endif %}{% if park.location.country and park.location.country != 'United States' %}, {{ park.location.country }}{% endif %}
{% else %}
Location not specified
{% endif %}
</p>
<div class="flex items-center justify-between">
<a href="{% url 'parks:rides:ride_list' park.slug %}"
class="text-sm text-blue-600 hover:underline dark:text-blue-400">
{{ park.ride_count }} attraction{{ park.ride_count|pluralize }}
</a>
{% if park.average_rating %}
<div class="flex items-center">
<span class="mr-1 text-yellow-400"></span>
<span class="text-sm">{{ park.average_rating|floatformat:1 }}/10</span>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Rides Results -->
{% if rides %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">
Rides & Attractions
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ rides|length }})</span>
</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for ride in rides %}
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700">
<!-- Ride Image -->
{% if ride.photos.exists %}
<img src="{{ ride.photos.first.image.url }}"
alt="{{ ride.name }}"
class="object-cover w-full h-48">
{% else %}
<div class="flex items-center justify-center w-full h-48 bg-gray-200 dark:bg-gray-600">
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
{% endif %}
<div class="p-4">
<h3 class="mb-2 text-lg font-semibold">
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
class="text-blue-600 hover:underline dark:text-blue-400">
{{ ride.name }}
</a>
</h3>
<p class="mb-2 text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
class="hover:underline">{{ ride.park.name }}</a>
</p>
<div class="flex flex-wrap gap-2 mb-2">
<span class="px-2 py-1 text-xs text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-200">
{{ ride.get_category_display }}
</span>
{% if ride.average_rating %}
<div class="flex items-center">
<span class="mr-1 text-yellow-400"></span>
<span class="text-sm">{{ ride.average_rating|floatformat:1 }}/10</span>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Operators Results -->
{% if operators %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">
Park Operators
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ operators|length }})</span>
</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for operator in operators %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700">
<h3 class="mb-2 text-lg font-semibold">
<span class="text-blue-600 dark:text-blue-400">
{{ operator.name }}
</span>
</h3>
{% if operator.description %}
<p class="mb-2 text-sm text-gray-600 dark:text-gray-400">
{{ operator.description|truncatewords:20 }}
</p>
{% endif %}
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ operator.operated_parks.count }} park{{ operator.operated_parks.count|pluralize }} operated
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Property Owners Results -->
{% if property_owners %}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">
Property Owners
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ property_owners|length }})</span>
</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for property_owner in property_owners %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700">
<h3 class="mb-2 text-lg font-semibold">
<span class="text-blue-600 dark:text-blue-400">
{{ property_owner.name }}
</span>
</h3>
{% if property_owner.description %}
<p class="mb-2 text-sm text-gray-600 dark:text-gray-400">
{{ property_owner.description|truncatewords:20 }}
</p>
{% endif %}
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ property_owner.owned_parks.count }} propert{{ property_owner.owned_parks.count|pluralize:"y,ies" }} owned
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
<!-- Initial Empty State (no search query) -->
{% if not search_query and not is_loading %}
<div class="p-12 text-center bg-gray-50 rounded-lg dark:bg-gray-800">
<svg class="mx-auto h-16 w-16 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100 mb-2">Search ThrillWiki</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
Find theme parks, roller coasters, rides, operators, and more from around the world.
</p>
<div class="text-sm text-gray-400 dark:text-gray-500">
<p>Try searching for:</p>
<div class="flex flex-wrap justify-center gap-2 mt-2">
<button
unicorn:click="on_search('Cedar Point')"
class="px-3 py-1 text-xs bg-gray-200 rounded-full hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600"
>
Cedar Point
</button>
<button
unicorn:click="on_search('roller coaster')"
class="px-3 py-1 text-xs bg-gray-200 rounded-full hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600"
>
roller coaster
</button>
<button
unicorn:click="on_search('Disney')"
class="px-3 py-1 text-xs bg-gray-200 rounded-full hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600"
>
Disney
</button>
</div>
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,59 @@
from django import template
from typing import Optional
from apps.parks.models import Park, Company
from apps.rides.models import Ride
register = template.Library()
@register.filter
def get_park_image_url(park: Park) -> Optional[str]:
"""Get park image URL with fallback."""
if hasattr(park, 'photos') and park.photos.exists():
photo = park.photos.first()
if hasattr(photo, 'image') and hasattr(photo.image, 'url'):
return photo.image.url
return None
@register.filter
def get_ride_image_url(ride: Ride) -> Optional[str]:
"""Get ride image URL with fallback."""
if hasattr(ride, 'photos') and ride.photos.exists():
photo = ride.photos.first()
if hasattr(photo, 'image') and hasattr(photo.image, 'url'):
return photo.image.url
return None
@register.filter
def get_park_location(park: Park) -> str:
"""Get formatted location string for park."""
if hasattr(park, 'location') and park.location:
location_parts = []
if park.location.city:
location_parts.append(park.location.city)
if park.location.state:
location_parts.append(park.location.state)
if park.location.country and park.location.country != 'United States':
location_parts.append(park.location.country)
return ', '.join(location_parts)
return "Location not specified"
@register.filter
def get_ride_category_display(ride: Ride) -> str:
"""Get human-readable ride category."""
if hasattr(ride, 'get_category_display'):
return ride.get_category_display()
return ride.category if hasattr(ride, 'category') else "Attraction"
@register.filter
def get_company_park_count(company: Company, role: str) -> int:
"""Get count of parks for company by role."""
if role == 'operator' and hasattr(company, 'operated_parks'):
return company.operated_parks.count()
elif role == 'owner' and hasattr(company, 'owned_parks'):
return company.owned_parks.count()
return 0