mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-03-30 09:58:23 -04:00
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:
277
backend/apps/core/components/filter_sidebar.py
Normal file
277
backend/apps/core/components/filter_sidebar.py
Normal 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()
|
||||
286
backend/apps/core/components/loading_states.py
Normal file
286
backend/apps/core/components/loading_states.py
Normal 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)
|
||||
323
backend/apps/core/components/modal_manager.py
Normal file
323
backend/apps/core/components/modal_manager.py
Normal 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
|
||||
220
backend/apps/core/components/pagination.py
Normal file
220
backend/apps/core/components/pagination.py
Normal 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
|
||||
213
backend/apps/core/components/search_form.py
Normal file
213
backend/apps/core/components/search_form.py
Normal 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
|
||||
288
backend/apps/core/components/search_results.py
Normal file
288
backend/apps/core/components/search_results.py
Normal 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,
|
||||
}
|
||||
326
backend/apps/core/templates/unicorn/filter-sidebar.html
Normal file
326
backend/apps/core/templates/unicorn/filter-sidebar.html
Normal 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>
|
||||
436
backend/apps/core/templates/unicorn/loading-states.html
Normal file
436
backend/apps/core/templates/unicorn/loading-states.html
Normal 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>
|
||||
127
backend/apps/core/templates/unicorn/pagination.html
Normal file
127
backend/apps/core/templates/unicorn/pagination.html
Normal 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>
|
||||
249
backend/apps/core/templates/unicorn/search-form.html
Normal file
249
backend/apps/core/templates/unicorn/search-form.html
Normal 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>
|
||||
275
backend/apps/core/templates/unicorn/search-results.html
Normal file
275
backend/apps/core/templates/unicorn/search-results.html
Normal 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>
|
||||
59
backend/apps/core/templatetags/search_filters.py
Normal file
59
backend/apps/core/templatetags/search_filters.py
Normal 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
|
||||
Reference in New Issue
Block a user