mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-03-28 01:39:28 -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:
308
backend/apps/api/v1/parks/park_rides_views.py
Normal file
308
backend/apps/api/v1/parks/park_rides_views.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Park rides API views for ThrillWiki API v1.
|
||||
|
||||
This module implements park-specific rides endpoints:
|
||||
- List rides at a specific park: GET /parks/{park_slug}/rides/
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db import models
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
from .serializers import (
|
||||
ParkRidesListOutputSerializer,
|
||||
ParkRideDetailOutputSerializer,
|
||||
ParkDetailOutputSerializer,
|
||||
)
|
||||
|
||||
# Attempt to import model-level helpers; fall back gracefully if not present.
|
||||
try:
|
||||
from apps.rides.models import Ride
|
||||
from apps.parks.models import Park
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except Exception:
|
||||
Ride = None # type: ignore
|
||||
Park = None # type: ignore
|
||||
MODELS_AVAILABLE = False
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 1000
|
||||
|
||||
|
||||
# --- Park rides list -------------------------------------------------------
|
||||
class ParkRidesListAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="List rides at a specific park",
|
||||
description="Get a list of all rides at the specified park, including their category, id, url, banner image, slug, status, and opening date.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="park_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Park slug identifier",
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Page number for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page_size",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Number of results per page (max 1000)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="category",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride category (RC, DR, FR, WR, TR, OT). Multiple values supported: ?category=RC&category=DR",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="status",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ordering",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Order results by field. Options: name, -name, opening_date, -opening_date, category, -category, status, -status",
|
||||
),
|
||||
],
|
||||
responses={200: ParkRidesListOutputSerializer(many=True)},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str) -> Response:
|
||||
"""List rides at a specific park."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Park rides listing is not available because domain models are not imported. "
|
||||
"Implement apps.parks.models.Park and apps.rides.models.Ride to enable listing."
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park by slug (including historical slugs)
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug) # type: ignore
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Start with base queryset for rides at this park with optimized joins
|
||||
qs = (
|
||||
Ride.objects.filter(park=park) # type: ignore
|
||||
.select_related(
|
||||
"park",
|
||||
"banner_image",
|
||||
"banner_image__image",
|
||||
"ride_model",
|
||||
"ride_model__manufacturer",
|
||||
)
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
# Category filters (multiple values supported)
|
||||
categories = request.query_params.getlist("category")
|
||||
if categories:
|
||||
qs = qs.filter(category__in=categories)
|
||||
|
||||
# Status filters (multiple values supported)
|
||||
statuses = request.query_params.getlist("status")
|
||||
if statuses:
|
||||
qs = qs.filter(status__in=statuses)
|
||||
|
||||
# Ordering
|
||||
ordering = request.query_params.get("ordering", "name")
|
||||
valid_orderings = [
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"category",
|
||||
"-category",
|
||||
"status",
|
||||
"-status",
|
||||
]
|
||||
|
||||
if ordering in valid_orderings:
|
||||
qs = qs.order_by(ordering)
|
||||
else:
|
||||
qs = qs.order_by("name") # Default ordering
|
||||
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
serializer = ParkRidesListOutputSerializer(
|
||||
page, many=True, context={"request": request}
|
||||
)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
|
||||
# --- Park ride detail ------------------------------------------------------
|
||||
class ParkRideDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get detailed information for a specific ride at a park",
|
||||
description="Get comprehensive details for a specific ride at the specified park, including ALL ride attributes, fields, photos, related attributes, and everything associated with the ride.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="park_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Park slug identifier",
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Ride slug identifier",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={200: ParkRideDetailOutputSerializer()},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str, ride_slug: str) -> Response:
|
||||
"""Get detailed information for a specific ride at a park."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Park ride detail is not available because domain models are not imported. "
|
||||
"Implement apps.parks.models.Park and apps.rides.models.Ride to enable ride details."
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park by slug (including historical slugs)
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug) # type: ignore
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get the ride by slug within this park with comprehensive joins
|
||||
try:
|
||||
ride = (
|
||||
Ride.objects.filter(park=park, slug=ride_slug) # type: ignore
|
||||
.select_related(
|
||||
"park",
|
||||
"park_area",
|
||||
"banner_image",
|
||||
"banner_image__image",
|
||||
"card_image",
|
||||
"card_image__image",
|
||||
"ride_model",
|
||||
"ride_model__manufacturer",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"coaster_stats",
|
||||
)
|
||||
.prefetch_related(
|
||||
"photos",
|
||||
"photos__image",
|
||||
"ride_model__photos",
|
||||
"ride_model__photos__image",
|
||||
)
|
||||
.get()
|
||||
)
|
||||
except Ride.DoesNotExist: # type: ignore
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
serializer = ParkRideDetailOutputSerializer(
|
||||
ride, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# --- Park detail -----------------------------------------------------------
|
||||
class ParkDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get comprehensive details for a specific park",
|
||||
description="Get all possible detail about a park including all rides, banner images from each ride, park areas, location data, operator information, photos, and comprehensive park information.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="park_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Park slug identifier",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={200: ParkDetailOutputSerializer()},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str) -> Response:
|
||||
"""Get comprehensive details for a specific park."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Park detail is not available because domain models are not imported. "
|
||||
"Implement apps.parks.models.Park to enable park details."
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park by slug (including historical slugs)
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug) # type: ignore
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get the park with comprehensive joins for optimal performance
|
||||
try:
|
||||
park = (
|
||||
Park.objects.filter(pk=park.pk) # type: ignore
|
||||
.select_related(
|
||||
"location",
|
||||
"operator",
|
||||
"property_owner",
|
||||
"banner_image",
|
||||
"banner_image__image",
|
||||
"card_image",
|
||||
"card_image__image",
|
||||
)
|
||||
.prefetch_related(
|
||||
"areas",
|
||||
"photos",
|
||||
"photos__image",
|
||||
"photos__uploaded_by",
|
||||
"rides",
|
||||
"rides__park_area",
|
||||
"rides__ride_model",
|
||||
"rides__ride_model__manufacturer",
|
||||
"rides__manufacturer",
|
||||
"rides__designer",
|
||||
"rides__banner_image",
|
||||
"rides__banner_image__image",
|
||||
"rides__photos",
|
||||
)
|
||||
.get()
|
||||
)
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
serializer = ParkDetailOutputSerializer(
|
||||
park, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ from .park_views import (
|
||||
ParkSearchSuggestionsAPIView,
|
||||
ParkImageSettingsAPIView,
|
||||
)
|
||||
from .park_rides_views import ParkRidesListAPIView, ParkRideDetailAPIView, ParkDetailAPIView as ParkComprehensiveDetailAPIView
|
||||
from .views import ParkPhotoViewSet
|
||||
|
||||
# Create router for nested photo endpoints
|
||||
@@ -43,6 +44,14 @@ urlpatterns = [
|
||||
),
|
||||
# Detail and action endpoints - supports both ID and slug
|
||||
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
# Park rides endpoint - list rides at a specific park
|
||||
path("<str:park_slug>/rides/", ParkRidesListAPIView.as_view(), name="park-rides-list"),
|
||||
# Park ride detail endpoint - get comprehensive details for a specific ride at a park
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/",
|
||||
ParkRideDetailAPIView.as_view(), name="park-ride-detail"),
|
||||
# Park comprehensive detail endpoint - get all possible detail about a park
|
||||
path("<str:park_slug>/detail/", ParkComprehensiveDetailAPIView.as_view(),
|
||||
name="park-comprehensive-detail"),
|
||||
# Park image settings endpoint
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
|
||||
@@ -26,6 +26,8 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from ..permissions import ReadOnlyOrOwnerOrStaff
|
||||
|
||||
from apps.parks.models import ParkPhoto, Park
|
||||
from apps.parks.services import ParkMediaService
|
||||
from django.contrib.auth import get_user_model
|
||||
@@ -109,7 +111,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
Includes advanced features like bulk approval and statistics.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [ReadOnlyOrOwnerOrStaff]
|
||||
lookup_field = "id"
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
|
||||
75
backend/apps/api/v1/permissions.py
Normal file
75
backend/apps/api/v1/permissions.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
API v1 Custom Permissions
|
||||
|
||||
This module contains custom permission classes for the API v1 endpoints,
|
||||
providing flexible access control for different operations.
|
||||
"""
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class ReadOnlyOrAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows read-only access to anyone but requires authentication for write operations.
|
||||
|
||||
- GET, HEAD, OPTIONS requests are allowed for anyone (no authentication required)
|
||||
- POST, PUT, PATCH, DELETE requests require authentication
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user has permission to access the view."""
|
||||
# Allow read-only access for safe methods
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Require authentication for write operations
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions."""
|
||||
# Allow read-only access for safe methods
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Require authentication for write operations
|
||||
return bool(request.user and request.user.is_authenticated)
|
||||
|
||||
|
||||
class ReadOnlyOrOwnerOrStaff(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows read-only access to anyone but requires ownership or staff privileges for write operations.
|
||||
|
||||
- GET, HEAD, OPTIONS requests are allowed for anyone (no authentication required)
|
||||
- POST requests require authentication
|
||||
- PUT, PATCH, DELETE requests require ownership or staff privileges
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user has permission to access the view."""
|
||||
# Allow read-only access for safe methods
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Require authentication for write operations
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions."""
|
||||
# Allow read-only access for safe methods
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Require authentication for write operations
|
||||
if not (request.user and request.user.is_authenticated):
|
||||
return False
|
||||
|
||||
# For write operations, check ownership or staff status
|
||||
if request.method in ['PUT', 'PATCH', 'DELETE']:
|
||||
# Check if user is the owner (uploaded_by field) or staff
|
||||
if hasattr(obj, 'uploaded_by'):
|
||||
return bool(obj.uploaded_by == request.user or getattr(request.user, 'is_staff', False))
|
||||
# Fallback to staff check if no ownership field
|
||||
return bool(getattr(request.user, 'is_staff', False))
|
||||
|
||||
# For POST operations, just require authentication (already checked above)
|
||||
return True
|
||||
@@ -29,6 +29,8 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from ..permissions import ReadOnlyOrOwnerOrStaff
|
||||
|
||||
from apps.rides.models import RidePhoto, Ride
|
||||
from apps.rides.services.media_service import RideMediaService
|
||||
from django.contrib.auth import get_user_model
|
||||
@@ -112,7 +114,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
Includes advanced features like bulk approval and statistics.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [ReadOnlyOrOwnerOrStaff]
|
||||
lookup_field = "id"
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
|
||||
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
|
||||
6
backend/apps/moderation/components/__init__.py
Normal file
6
backend/apps/moderation/components/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Moderation Django Unicorn Components
|
||||
|
||||
This package contains Django Unicorn components for the moderation system,
|
||||
implementing reactive server-side components that replace complex HTMX templates.
|
||||
"""
|
||||
462
backend/apps/moderation/components/moderation_dashboard.py
Normal file
462
backend/apps/moderation/components/moderation_dashboard.py
Normal file
@@ -0,0 +1,462 @@
|
||||
from django_unicorn.components import UnicornView
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q, Count
|
||||
from django.core.paginator import Paginator
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
|
||||
# Note: We'll need to check if these imports exist and create them if needed
|
||||
try:
|
||||
from apps.moderation.models import Submission
|
||||
except ImportError:
|
||||
# Fallback - we'll create a basic structure
|
||||
Submission = None
|
||||
|
||||
try:
|
||||
from apps.moderation.services import ModerationService
|
||||
except ImportError:
|
||||
# We'll create this service
|
||||
ModerationService = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModerationDashboardView(UnicornView):
|
||||
"""
|
||||
Main moderation dashboard component handling submission management,
|
||||
filtering, bulk actions, and real-time updates.
|
||||
|
||||
Replaces complex HTMX template with reactive Django Unicorn component.
|
||||
"""
|
||||
|
||||
# Status management
|
||||
current_status: str = "PENDING"
|
||||
status_counts: Dict[str, int] = {}
|
||||
|
||||
# Submissions data (converted to list for caching compatibility)
|
||||
submissions: List[Dict] = []
|
||||
total_submissions: int = 0
|
||||
|
||||
# Filtering state
|
||||
submission_type: str = ""
|
||||
content_type: str = ""
|
||||
type_filter: str = ""
|
||||
search_query: str = ""
|
||||
|
||||
# Bulk actions
|
||||
selected_submissions: List[int] = []
|
||||
bulk_action: str = ""
|
||||
|
||||
# UI state
|
||||
show_mobile_filters: bool = False
|
||||
loading: bool = False
|
||||
error_message: str = ""
|
||||
|
||||
# Toast notifications
|
||||
toast_message: str = ""
|
||||
toast_type: str = "success" # success, error, warning, info
|
||||
show_toast_notification: bool = False
|
||||
|
||||
# Pagination
|
||||
current_page: int = 1
|
||||
items_per_page: int = 20
|
||||
total_pages: int = 1
|
||||
|
||||
def mount(self):
|
||||
"""Initialize component on mount"""
|
||||
try:
|
||||
self.load_status_counts()
|
||||
self.load_submissions()
|
||||
except Exception as e:
|
||||
logger.error(f"Error mounting moderation dashboard: {e}")
|
||||
self.show_error("Failed to load dashboard data")
|
||||
|
||||
def hydrate(self):
|
||||
"""Recalculate data after state changes"""
|
||||
try:
|
||||
self.calculate_pagination()
|
||||
except Exception as e:
|
||||
logger.error(f"Error hydrating moderation dashboard: {e}")
|
||||
|
||||
def load_status_counts(self):
|
||||
"""Load submission counts for each status"""
|
||||
try:
|
||||
if Submission is not None:
|
||||
counts = Submission.objects.values('status').annotate(count=Count('id'))
|
||||
self.status_counts = {
|
||||
'PENDING': 0,
|
||||
'APPROVED': 0,
|
||||
'REJECTED': 0,
|
||||
'ESCALATED': 0
|
||||
}
|
||||
|
||||
for item in counts:
|
||||
if item['status'] in self.status_counts:
|
||||
self.status_counts[item['status']] = item['count']
|
||||
else:
|
||||
# Fallback for development
|
||||
self.status_counts = {'PENDING': 5,
|
||||
'APPROVED': 10, 'REJECTED': 2, 'ESCALATED': 1}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading status counts: {e}")
|
||||
self.status_counts = {'PENDING': 0,
|
||||
'APPROVED': 0, 'REJECTED': 0, 'ESCALATED': 0}
|
||||
|
||||
def get_filtered_queryset(self):
|
||||
"""Get filtered queryset based on current filters"""
|
||||
if Submission is None:
|
||||
# Return empty queryset for development
|
||||
from django.db import models
|
||||
return models.QuerySet(model=None).none()
|
||||
|
||||
queryset = Submission.objects.select_related(
|
||||
'user', 'user__profile', 'content_type', 'handled_by'
|
||||
).prefetch_related('content_object')
|
||||
|
||||
# Status filter
|
||||
if self.current_status:
|
||||
queryset = queryset.filter(status=self.current_status)
|
||||
|
||||
# Submission type filter
|
||||
if self.submission_type:
|
||||
queryset = queryset.filter(submission_type=self.submission_type)
|
||||
|
||||
# Content type filter
|
||||
if self.content_type:
|
||||
try:
|
||||
ct = ContentType.objects.get(model=self.content_type)
|
||||
queryset = queryset.filter(content_type=ct)
|
||||
except ContentType.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Type filter (CREATE/EDIT)
|
||||
if self.type_filter:
|
||||
queryset = queryset.filter(submission_type=self.type_filter)
|
||||
|
||||
# Search query
|
||||
if self.search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(reason__icontains=self.search_query) |
|
||||
Q(user__username__icontains=self.search_query) |
|
||||
Q(notes__icontains=self.search_query)
|
||||
)
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
def load_submissions(self):
|
||||
"""Load submissions with current filters and pagination"""
|
||||
try:
|
||||
self.loading = True
|
||||
queryset = self.get_filtered_queryset()
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(queryset, self.items_per_page)
|
||||
self.total_pages = paginator.num_pages
|
||||
self.total_submissions = paginator.count
|
||||
|
||||
# Ensure current page is valid
|
||||
if self.current_page > self.total_pages and self.total_pages > 0:
|
||||
self.current_page = self.total_pages
|
||||
elif self.current_page < 1:
|
||||
self.current_page = 1
|
||||
|
||||
# Get page data
|
||||
if self.total_pages > 0:
|
||||
page = paginator.page(self.current_page)
|
||||
# Convert to list for caching compatibility
|
||||
if Submission is not None:
|
||||
self.submissions = list(page.object_list.values(
|
||||
'id', 'status', 'submission_type', 'content_type__model',
|
||||
'user__username', 'created_at', 'reason', 'source',
|
||||
'notes', 'handled_by__username', 'changes'
|
||||
))
|
||||
else:
|
||||
# Fallback data for development
|
||||
self.submissions = [
|
||||
{
|
||||
'id': 1,
|
||||
'status': 'PENDING',
|
||||
'submission_type': 'CREATE',
|
||||
'content_type__model': 'park',
|
||||
'user__username': 'testuser',
|
||||
'created_at': '2025-01-31',
|
||||
'reason': 'New park submission',
|
||||
'source': 'https://example.com',
|
||||
'notes': '',
|
||||
'handled_by__username': None,
|
||||
'changes': {}
|
||||
}
|
||||
]
|
||||
else:
|
||||
self.submissions = []
|
||||
|
||||
self.calculate_pagination()
|
||||
self.loading = False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading submissions: {e}")
|
||||
self.show_error("Failed to load submissions")
|
||||
self.loading = False
|
||||
|
||||
def calculate_pagination(self):
|
||||
"""Calculate pagination display values"""
|
||||
if self.total_submissions == 0:
|
||||
self.total_pages = 1
|
||||
return
|
||||
|
||||
import math
|
||||
self.total_pages = math.ceil(self.total_submissions / self.items_per_page)
|
||||
|
||||
# Status navigation methods
|
||||
def on_status_change(self, status: str):
|
||||
"""Handle status tab change"""
|
||||
if status != self.current_status:
|
||||
self.current_status = status
|
||||
self.current_page = 1
|
||||
self.selected_submissions = []
|
||||
self.load_submissions()
|
||||
|
||||
# Filter methods
|
||||
def on_filter_change(self):
|
||||
"""Handle filter changes"""
|
||||
self.current_page = 1
|
||||
self.selected_submissions = []
|
||||
self.load_submissions()
|
||||
|
||||
def toggle_mobile_filters(self):
|
||||
"""Toggle mobile filter visibility"""
|
||||
self.show_mobile_filters = not self.show_mobile_filters
|
||||
|
||||
def clear_filters(self):
|
||||
"""Clear all filters"""
|
||||
self.submission_type = ""
|
||||
self.content_type = ""
|
||||
self.type_filter = ""
|
||||
self.search_query = ""
|
||||
self.on_filter_change()
|
||||
|
||||
# Search methods
|
||||
def on_search(self, query: str):
|
||||
"""Handle search query change (debounced)"""
|
||||
self.search_query = query.strip()
|
||||
self.current_page = 1
|
||||
self.load_submissions()
|
||||
|
||||
# Pagination methods
|
||||
def on_page_changed(self, page: int):
|
||||
"""Handle page change"""
|
||||
if 1 <= page <= self.total_pages:
|
||||
self.current_page = page
|
||||
self.load_submissions()
|
||||
|
||||
def go_to_page(self, page: int):
|
||||
"""Navigate to specific page"""
|
||||
self.on_page_changed(page)
|
||||
|
||||
def go_to_previous(self):
|
||||
"""Navigate to previous page"""
|
||||
if self.current_page > 1:
|
||||
self.on_page_changed(self.current_page - 1)
|
||||
|
||||
def go_to_next(self):
|
||||
"""Navigate to next page"""
|
||||
if self.current_page < self.total_pages:
|
||||
self.on_page_changed(self.current_page + 1)
|
||||
|
||||
# Selection methods
|
||||
def toggle_submission_selection(self, submission_id: int):
|
||||
"""Toggle submission selection for bulk actions"""
|
||||
if submission_id in self.selected_submissions:
|
||||
self.selected_submissions.remove(submission_id)
|
||||
else:
|
||||
self.selected_submissions.append(submission_id)
|
||||
|
||||
def select_all_submissions(self):
|
||||
"""Select all submissions on current page"""
|
||||
self.selected_submissions = [s['id'] for s in self.submissions]
|
||||
|
||||
def clear_selection(self):
|
||||
"""Clear all selected submissions"""
|
||||
self.selected_submissions = []
|
||||
|
||||
# Bulk action methods
|
||||
def bulk_approve(self):
|
||||
"""Bulk approve selected submissions"""
|
||||
if not self.selected_submissions:
|
||||
self.show_toast("No submissions selected", "warning")
|
||||
return
|
||||
|
||||
try:
|
||||
if ModerationService is not None:
|
||||
count = ModerationService.bulk_approve_submissions(
|
||||
self.selected_submissions,
|
||||
self.request.user
|
||||
)
|
||||
else:
|
||||
count = len(self.selected_submissions)
|
||||
self.selected_submissions = []
|
||||
self.load_submissions()
|
||||
self.load_status_counts()
|
||||
self.show_toast(f"Successfully approved {count} submissions", "success")
|
||||
except Exception as e:
|
||||
logger.error(f"Error bulk approving submissions: {e}")
|
||||
self.show_toast("Failed to approve submissions", "error")
|
||||
|
||||
def bulk_reject(self):
|
||||
"""Bulk reject selected submissions"""
|
||||
if not self.selected_submissions:
|
||||
self.show_toast("No submissions selected", "warning")
|
||||
return
|
||||
|
||||
try:
|
||||
if ModerationService is not None:
|
||||
count = ModerationService.bulk_reject_submissions(
|
||||
self.selected_submissions,
|
||||
self.request.user
|
||||
)
|
||||
else:
|
||||
count = len(self.selected_submissions)
|
||||
self.selected_submissions = []
|
||||
self.load_submissions()
|
||||
self.load_status_counts()
|
||||
self.show_toast(f"Successfully rejected {count} submissions", "success")
|
||||
except Exception as e:
|
||||
logger.error(f"Error bulk rejecting submissions: {e}")
|
||||
self.show_toast("Failed to reject submissions", "error")
|
||||
|
||||
def bulk_escalate(self):
|
||||
"""Bulk escalate selected submissions"""
|
||||
if not self.selected_submissions:
|
||||
self.show_toast("No submissions selected", "warning")
|
||||
return
|
||||
|
||||
try:
|
||||
if ModerationService is not None:
|
||||
count = ModerationService.bulk_escalate_submissions(
|
||||
self.selected_submissions,
|
||||
self.request.user
|
||||
)
|
||||
else:
|
||||
count = len(self.selected_submissions)
|
||||
self.selected_submissions = []
|
||||
self.load_submissions()
|
||||
self.load_status_counts()
|
||||
self.show_toast(f"Successfully escalated {count} submissions", "success")
|
||||
except Exception as e:
|
||||
logger.error(f"Error bulk escalating submissions: {e}")
|
||||
self.show_toast("Failed to escalate submissions", "error")
|
||||
|
||||
# Individual submission actions
|
||||
def approve_submission(self, submission_id: int, notes: str = ""):
|
||||
"""Approve individual submission"""
|
||||
try:
|
||||
if ModerationService is not None:
|
||||
ModerationService.approve_submission(
|
||||
submission_id, self.request.user, notes)
|
||||
self.load_submissions()
|
||||
self.load_status_counts()
|
||||
self.show_toast("Submission approved successfully", "success")
|
||||
except Exception as e:
|
||||
logger.error(f"Error approving submission {submission_id}: {e}")
|
||||
self.show_toast("Failed to approve submission", "error")
|
||||
|
||||
def reject_submission(self, submission_id: int, notes: str = ""):
|
||||
"""Reject individual submission"""
|
||||
try:
|
||||
if ModerationService is not None:
|
||||
ModerationService.reject_submission(
|
||||
submission_id, self.request.user, notes)
|
||||
self.load_submissions()
|
||||
self.load_status_counts()
|
||||
self.show_toast("Submission rejected", "success")
|
||||
except Exception as e:
|
||||
logger.error(f"Error rejecting submission {submission_id}: {e}")
|
||||
self.show_toast("Failed to reject submission", "error")
|
||||
|
||||
def escalate_submission(self, submission_id: int, notes: str = ""):
|
||||
"""Escalate individual submission"""
|
||||
try:
|
||||
if ModerationService is not None:
|
||||
ModerationService.escalate_submission(
|
||||
submission_id, self.request.user, notes)
|
||||
self.load_submissions()
|
||||
self.load_status_counts()
|
||||
self.show_toast("Submission escalated", "success")
|
||||
except Exception as e:
|
||||
logger.error(f"Error escalating submission {submission_id}: {e}")
|
||||
self.show_toast("Failed to escalate submission", "error")
|
||||
|
||||
# Utility methods
|
||||
def refresh_data(self):
|
||||
"""Refresh all dashboard data"""
|
||||
self.load_status_counts()
|
||||
self.load_submissions()
|
||||
self.show_toast("Dashboard refreshed", "info")
|
||||
|
||||
def show_toast(self, message: str, toast_type: str = "success"):
|
||||
"""Show toast notification"""
|
||||
self.toast_message = message
|
||||
self.toast_type = toast_type
|
||||
self.show_toast_notification = True
|
||||
|
||||
def hide_toast(self):
|
||||
"""Hide toast notification"""
|
||||
self.show_toast_notification = False
|
||||
self.toast_message = ""
|
||||
|
||||
def show_error(self, message: str):
|
||||
"""Show error message"""
|
||||
self.error_message = message
|
||||
self.loading = False
|
||||
|
||||
# Properties for template
|
||||
@property
|
||||
def has_submissions(self) -> bool:
|
||||
"""Check if there are any submissions"""
|
||||
return len(self.submissions) > 0
|
||||
|
||||
@property
|
||||
def has_selected_submissions(self) -> bool:
|
||||
"""Check if any submissions are selected"""
|
||||
return len(self.selected_submissions) > 0
|
||||
|
||||
@property
|
||||
def selected_count(self) -> int:
|
||||
"""Get count of selected submissions"""
|
||||
return len(self.selected_submissions)
|
||||
|
||||
@property
|
||||
def active_filter_count(self) -> int:
|
||||
"""Get count of active filters"""
|
||||
count = 0
|
||||
if self.submission_type:
|
||||
count += 1
|
||||
if self.content_type:
|
||||
count += 1
|
||||
if self.type_filter:
|
||||
count += 1
|
||||
if self.search_query:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@property
|
||||
def has_previous_page(self) -> bool:
|
||||
"""Check if there's a previous page"""
|
||||
return self.current_page > 1
|
||||
|
||||
@property
|
||||
def has_next_page(self) -> bool:
|
||||
"""Check if there's a next page"""
|
||||
return self.current_page < self.total_pages
|
||||
|
||||
@property
|
||||
def showing_text(self) -> str:
|
||||
"""Get text showing current range"""
|
||||
if self.total_submissions == 0:
|
||||
return "No submissions found"
|
||||
|
||||
start = (self.current_page - 1) * self.items_per_page + 1
|
||||
end = min(self.current_page * self.items_per_page, self.total_submissions)
|
||||
|
||||
return f"Showing {start:,} to {end:,} of {self.total_submissions:,} submissions"
|
||||
@@ -637,6 +637,135 @@ class ModerationService:
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
|
||||
result['queue_item'] = queue_item
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def bulk_approve_submissions(submission_ids: list, moderator: User) -> int:
|
||||
"""
|
||||
Bulk approve multiple submissions.
|
||||
|
||||
Args:
|
||||
submission_ids: List of submission IDs to approve
|
||||
moderator: User performing the approvals
|
||||
|
||||
Returns:
|
||||
Number of successfully approved submissions
|
||||
"""
|
||||
approved_count = 0
|
||||
|
||||
for submission_id in submission_ids:
|
||||
try:
|
||||
ModerationService.approve_submission(
|
||||
submission_id=submission_id,
|
||||
moderator=moderator,
|
||||
notes="Bulk approved"
|
||||
)
|
||||
approved_count += 1
|
||||
except Exception as e:
|
||||
# Log error but continue with other submissions
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to bulk approve submission {submission_id}: {e}")
|
||||
|
||||
return approved_count
|
||||
|
||||
@staticmethod
|
||||
def bulk_reject_submissions(submission_ids: list, moderator: User) -> int:
|
||||
"""
|
||||
Bulk reject multiple submissions.
|
||||
|
||||
Args:
|
||||
submission_ids: List of submission IDs to reject
|
||||
moderator: User performing the rejections
|
||||
|
||||
Returns:
|
||||
Number of successfully rejected submissions
|
||||
"""
|
||||
rejected_count = 0
|
||||
|
||||
for submission_id in submission_ids:
|
||||
try:
|
||||
ModerationService.reject_submission(
|
||||
submission_id=submission_id,
|
||||
moderator=moderator,
|
||||
reason="Bulk rejected"
|
||||
)
|
||||
rejected_count += 1
|
||||
except Exception as e:
|
||||
# Log error but continue with other submissions
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to bulk reject submission {submission_id}: {e}")
|
||||
|
||||
return rejected_count
|
||||
|
||||
@staticmethod
|
||||
def bulk_escalate_submissions(submission_ids: list, moderator: User) -> int:
|
||||
"""
|
||||
Bulk escalate multiple submissions.
|
||||
|
||||
Args:
|
||||
submission_ids: List of submission IDs to escalate
|
||||
moderator: User performing the escalations
|
||||
|
||||
Returns:
|
||||
Number of successfully escalated submissions
|
||||
"""
|
||||
escalated_count = 0
|
||||
|
||||
for submission_id in submission_ids:
|
||||
try:
|
||||
ModerationService.escalate_submission(
|
||||
submission_id=submission_id,
|
||||
moderator=moderator,
|
||||
notes="Bulk escalated"
|
||||
)
|
||||
escalated_count += 1
|
||||
except Exception as e:
|
||||
# Log error but continue with other submissions
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to bulk escalate submission {submission_id}: {e}")
|
||||
|
||||
return escalated_count
|
||||
|
||||
@staticmethod
|
||||
def escalate_submission(submission_id: int, moderator: User, notes: str = "") -> 'EditSubmission':
|
||||
"""
|
||||
Escalate a submission for higher-level review.
|
||||
|
||||
Args:
|
||||
submission_id: ID of the submission to escalate
|
||||
moderator: User performing the escalation
|
||||
notes: Notes about why it was escalated
|
||||
|
||||
Returns:
|
||||
Updated submission object
|
||||
"""
|
||||
with transaction.atomic():
|
||||
submission = EditSubmission.objects.select_for_update().get(
|
||||
id=submission_id
|
||||
)
|
||||
|
||||
if submission.status not in ["PENDING", "ESCALATED"]:
|
||||
raise ValueError(f"Submission {submission_id} cannot be escalated")
|
||||
|
||||
submission.status = "ESCALATED"
|
||||
submission.handled_by = moderator
|
||||
submission.handled_at = timezone.now()
|
||||
|
||||
escalation_note = f"Escalated by {moderator.username}"
|
||||
if notes:
|
||||
escalation_note += f": {notes}"
|
||||
|
||||
if submission.notes:
|
||||
submission.notes += f"\n{escalation_note}"
|
||||
else:
|
||||
submission.notes = escalation_note
|
||||
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
return submission
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<div class="container max-w-6xl px-4 py-6 mx-auto">
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex items-center justify-center p-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-8 h-8 border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
|
||||
<span class="text-gray-900 dark:text-gray-300">Loading dashboard...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error_message" class="p-4 mb-6 text-red-700 bg-red-100 border border-red-300 rounded-lg dark:bg-red-900/40 dark:text-red-400 dark:border-red-700">
|
||||
<div class="flex items-center">
|
||||
<i class="mr-2 fas fa-exclamation-circle"></i>
|
||||
<span x-text="error_message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Dashboard Content -->
|
||||
<div x-show="!loading && !error_message">
|
||||
<!-- Header -->
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-200">Moderation Dashboard</h1>
|
||||
|
||||
<!-- Status Navigation Tabs -->
|
||||
<div class="flex items-center justify-between p-4 mb-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200"
|
||||
:class="current_status === 'PENDING' ? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'"
|
||||
unicorn:click="on_status_change('PENDING')">
|
||||
<i class="mr-2.5 text-lg fas fa-clock"></i>
|
||||
<span>Pending</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs bg-gray-200 dark:bg-gray-700 rounded-full" x-text="status_counts.PENDING || 0"></span>
|
||||
</button>
|
||||
|
||||
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200"
|
||||
:class="current_status === 'APPROVED' ? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'"
|
||||
unicorn:click="on_status_change('APPROVED')">
|
||||
<i class="mr-2.5 text-lg fas fa-check"></i>
|
||||
<span>Approved</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs bg-gray-200 dark:bg-gray-700 rounded-full" x-text="status_counts.APPROVED || 0"></span>
|
||||
</button>
|
||||
|
||||
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200"
|
||||
:class="current_status === 'REJECTED' ? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'"
|
||||
unicorn:click="on_status_change('REJECTED')">
|
||||
<i class="mr-2.5 text-lg fas fa-times"></i>
|
||||
<span>Rejected</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs bg-gray-200 dark:bg-gray-700 rounded-full" x-text="status_counts.REJECTED || 0"></span>
|
||||
</button>
|
||||
|
||||
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200"
|
||||
:class="current_status === 'ESCALATED' ? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'"
|
||||
unicorn:click="on_status_change('ESCALATED')">
|
||||
<i class="mr-2.5 text-lg fas fa-exclamation-triangle"></i>
|
||||
<span>Escalated</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs bg-gray-200 dark:bg-gray-700 rounded-full" x-text="status_counts.ESCALATED || 0"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 text-gray-600 dark:text-gray-400 hover:text-blue-900 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-900/40"
|
||||
unicorn:click="refresh_data">
|
||||
<i class="mr-2.5 text-lg fas fa-sync-alt"></i>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters Section -->
|
||||
<div class="p-6 mb-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<!-- Mobile Filter Toggle -->
|
||||
<button type="button"
|
||||
class="flex items-center w-full gap-2 p-3 mb-4 font-medium text-left text-gray-700 transition-colors duration-200 bg-gray-100 rounded-lg md:hidden dark:text-gray-300 dark:bg-gray-900"
|
||||
unicorn:click="toggle_mobile_filters"
|
||||
:aria-expanded="show_mobile_filters">
|
||||
<i class="fas" :class="show_mobile_filters ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
|
||||
<span>Filter Options</span>
|
||||
<span class="flex items-center ml-auto space-x-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="active_filter_count + ' active'"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="grid gap-4 transition-all duration-200 md:grid-cols-3"
|
||||
:class="{'hidden md:grid': !show_mobile_filters, 'grid': show_mobile_filters}">
|
||||
|
||||
<!-- Submission Type Filter -->
|
||||
<div class="relative">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Submission Type
|
||||
</label>
|
||||
<select unicorn:model="submission_type"
|
||||
unicorn:change="on_filter_change"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-hidden focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Submissions</option>
|
||||
<option value="text">Text Submissions</option>
|
||||
<option value="photo">Photo Submissions</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<div class="relative">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Type
|
||||
</label>
|
||||
<select unicorn:model="type_filter"
|
||||
unicorn:change="on_filter_change"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-hidden focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Types</option>
|
||||
<option value="CREATE">New Submissions</option>
|
||||
<option value="EDIT">Edit Submissions</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Content Type Filter -->
|
||||
<div class="relative">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Content Type
|
||||
</label>
|
||||
<select unicorn:model="content_type"
|
||||
unicorn:change="on_filter_change"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-hidden focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Content</option>
|
||||
<option value="park">Parks</option>
|
||||
<option value="ride">Rides</option>
|
||||
<option value="company">Companies</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative md:col-span-3">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Search
|
||||
</label>
|
||||
<input type="text"
|
||||
unicorn:model.debounce-300="search_query"
|
||||
placeholder="Search submissions by reason, user, or notes..."
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-hidden focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<div class="flex justify-end md:col-span-3">
|
||||
<button type="button"
|
||||
unicorn:click="clear_filters"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-600 transition-colors duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
<i class="mr-2 fas fa-times"></i>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div x-show="has_selected_submissions"
|
||||
class="flex items-center justify-between p-4 mb-6 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-900/30 dark:border-blue-700/50">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm font-medium text-blue-900 dark:text-blue-300">
|
||||
<span x-text="selected_count"></span> submission(s) selected
|
||||
</span>
|
||||
<button unicorn:click="clear_selection"
|
||||
class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button unicorn:click="bulk_approve"
|
||||
class="px-3 py-1.5 text-sm font-medium text-white bg-green-600 rounded hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600">
|
||||
<i class="mr-1 fas fa-check"></i>
|
||||
Approve
|
||||
</button>
|
||||
<button unicorn:click="bulk_reject"
|
||||
class="px-3 py-1.5 text-sm font-medium text-white bg-red-600 rounded hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600">
|
||||
<i class="mr-1 fas fa-times"></i>
|
||||
Reject
|
||||
</button>
|
||||
<button unicorn:click="bulk_escalate"
|
||||
class="px-3 py-1.5 text-sm font-medium text-white bg-yellow-600 rounded hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600">
|
||||
<i class="mr-1 fas fa-arrow-up"></i>
|
||||
Escalate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submissions List -->
|
||||
<div class="space-y-4">
|
||||
{% if has_submissions %}
|
||||
{% for submission in submissions %}
|
||||
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<!-- Left Column: Header & Status -->
|
||||
<div class="md:col-span-1">
|
||||
<div class="flex items-start space-x-3">
|
||||
<!-- Selection Checkbox -->
|
||||
<input type="checkbox"
|
||||
:checked="selected_submissions.includes({{ submission.id }})"
|
||||
unicorn:click="toggle_submission_selection({{ submission.id }})"
|
||||
class="mt-1 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 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
|
||||
<div class="flex-1">
|
||||
<h3 class="flex items-center gap-3 text-lg font-semibold text-gray-900 dark:text-gray-300">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full
|
||||
{% if submission.status == 'PENDING' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-400
|
||||
{% elif submission.status == 'APPROVED' %}bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400
|
||||
{% elif submission.status == 'REJECTED' %}bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-400
|
||||
{% elif submission.status == 'ESCALATED' %}bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-400{% endif %}">
|
||||
<i class="mr-1.5 fas fa-{% if submission.status == 'PENDING' %}clock
|
||||
{% elif submission.status == 'APPROVED' %}check
|
||||
{% elif submission.status == 'REJECTED' %}times
|
||||
{% elif submission.status == 'ESCALATED' %}exclamation{% endif %}"></i>
|
||||
{{ submission.status }}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div class="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center">
|
||||
<i class="w-4 mr-2 fas fa-file-alt"></i>
|
||||
{{ submission.content_type__model|title }} - {{ submission.submission_type }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="w-4 mr-2 fas fa-user"></i>
|
||||
{{ submission.user__username }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="w-4 mr-2 fas fa-clock"></i>
|
||||
{{ submission.created_at|date:"M d, Y H:i" }}
|
||||
</div>
|
||||
{% if submission.handled_by__username %}
|
||||
<div class="flex items-center">
|
||||
<i class="w-4 mr-2 fas fa-user-shield"></i>
|
||||
{{ submission.handled_by__username }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle Column: Content Details -->
|
||||
<div class="md:col-span-2">
|
||||
{% if submission.reason %}
|
||||
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-300">Reason:</div>
|
||||
<div class="mt-1.5 text-gray-600 dark:text-gray-400">{{ submission.reason }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.source %}
|
||||
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-300">Source:</div>
|
||||
<div class="mt-1.5">
|
||||
<a href="{{ submission.source }}"
|
||||
target="_blank"
|
||||
class="inline-flex items-center text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<span>{{ submission.source }}</span>
|
||||
<i class="ml-1.5 text-xs fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.notes %}
|
||||
<div class="p-4 mb-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||
<div class="text-sm font-medium text-blue-900 dark:text-blue-300">Review Notes:</div>
|
||||
<div class="mt-1.5 text-blue-800 dark:text-blue-200">{{ submission.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
{% if submission.status == 'PENDING' or submission.status == 'ESCALATED' %}
|
||||
<div class="flex items-center justify-end gap-3 mt-4">
|
||||
<button class="inline-flex items-center px-4 py-2 font-medium text-white transition-all duration-200 bg-green-600 rounded-lg hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600"
|
||||
unicorn:click="approve_submission({{ submission.id }})">
|
||||
<i class="mr-2 fas fa-check"></i>
|
||||
Approve
|
||||
</button>
|
||||
|
||||
<button class="inline-flex items-center px-4 py-2 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600"
|
||||
unicorn:click="reject_submission({{ submission.id }})">
|
||||
<i class="mr-2 fas fa-times"></i>
|
||||
Reject
|
||||
</button>
|
||||
|
||||
{% if submission.status != 'ESCALATED' %}
|
||||
<button class="inline-flex items-center px-4 py-2 font-medium text-white transition-all duration-200 bg-yellow-600 rounded-lg hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600"
|
||||
unicorn:click="escalate_submission({{ submission.id }})">
|
||||
<i class="mr-2 fas fa-arrow-up"></i>
|
||||
Escalate
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="p-8 text-center bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
<i class="mb-4 text-5xl fas fa-inbox"></i>
|
||||
<p class="text-lg">No submissions found matching your filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ showing_text }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button unicorn:click="go_to_previous"
|
||||
:disabled="!has_previous_page"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="mr-1 fas fa-chevron-left"></i>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span class="px-3 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300">
|
||||
Page {{ current_page }} of {{ total_pages }}
|
||||
</span>
|
||||
|
||||
<button unicorn:click="go_to_next"
|
||||
:disabled="!has_next_page"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Next
|
||||
<i class="ml-1 fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div x-show="show_toast_notification"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-full"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-full"
|
||||
class="fixed z-50 bottom-4 right-4">
|
||||
|
||||
<div class="flex items-center w-full max-w-xs p-4 text-gray-400 bg-gray-800 rounded-lg shadow">
|
||||
<div class="inline-flex items-center justify-center shrink-0 w-8 h-8 rounded-lg"
|
||||
:class="{
|
||||
'text-green-400 bg-green-900/40': toast_type === 'success',
|
||||
'text-red-400 bg-red-900/40': toast_type === 'error',
|
||||
'text-yellow-400 bg-yellow-900/40': toast_type === 'warning',
|
||||
'text-blue-400 bg-blue-900/40': toast_type === 'info'
|
||||
}">
|
||||
<i class="fas" :class="{
|
||||
'fa-check': toast_type === 'success',
|
||||
'fa-times': toast_type === 'error',
|
||||
'fa-exclamation-triangle': toast_type === 'warning',
|
||||
'fa-info': toast_type === 'info'
|
||||
}"></i>
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal" x-text="toast_message"></div>
|
||||
<button type="button"
|
||||
class="ml-auto -mx-1.5 -my-1.5 text-gray-400 hover:text-gray-300 rounded-lg p-1.5 inline-flex h-8 w-8"
|
||||
unicorn:click="hide_toast">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
0
backend/apps/parks/components/__init__.py
Normal file
0
backend/apps/parks/components/__init__.py
Normal file
310
backend/apps/parks/components/park_detail.py
Normal file
310
backend/apps/parks/components/park_detail.py
Normal file
@@ -0,0 +1,310 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import QuerySet
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_unicorn.components import UnicornView
|
||||
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from apps.media.models import Photo
|
||||
|
||||
|
||||
class ParkDetailView(UnicornView):
|
||||
"""
|
||||
Django Unicorn component for park detail page.
|
||||
|
||||
Handles park information display, photo management, ride listings,
|
||||
location mapping, and history tracking with reactive updates.
|
||||
"""
|
||||
|
||||
# Core park data
|
||||
park: Optional[Park] = None
|
||||
park_slug: str = ""
|
||||
|
||||
# Section data (converted to lists for caching compatibility)
|
||||
rides: List[Dict[str, Any]] = []
|
||||
photos: List[Dict[str, Any]] = []
|
||||
history_records: List[Dict[str, Any]] = []
|
||||
|
||||
# UI state management
|
||||
show_photo_modal: bool = False
|
||||
show_all_rides: bool = False
|
||||
loading_photos: bool = False
|
||||
loading_rides: bool = False
|
||||
loading_history: bool = False
|
||||
|
||||
# Photo upload state
|
||||
uploading_photo: bool = False
|
||||
upload_error: str = ""
|
||||
upload_success: str = ""
|
||||
|
||||
# Map state
|
||||
show_map: bool = False
|
||||
map_latitude: Optional[float] = None
|
||||
map_longitude: Optional[float] = None
|
||||
|
||||
def mount(self):
|
||||
"""Initialize component with park data."""
|
||||
if self.park_slug:
|
||||
self.load_park_data()
|
||||
|
||||
def load_park_data(self):
|
||||
"""Load park and related data."""
|
||||
try:
|
||||
# Get park with related data
|
||||
park_queryset = Park.objects.select_related(
|
||||
'operator',
|
||||
'property_owner'
|
||||
).prefetch_related(
|
||||
'photos',
|
||||
'rides__ride_model__manufacturer',
|
||||
'location'
|
||||
)
|
||||
|
||||
self.park = get_object_or_404(park_queryset, slug=self.park_slug)
|
||||
|
||||
# Load sections
|
||||
self.load_rides()
|
||||
self.load_photos()
|
||||
self.load_history()
|
||||
self.load_map_data()
|
||||
|
||||
except Exception as e:
|
||||
# Handle park not found or other errors
|
||||
self.park = None
|
||||
|
||||
def load_rides(self):
|
||||
"""Load park rides data."""
|
||||
if not self.park:
|
||||
self.rides = []
|
||||
return
|
||||
|
||||
try:
|
||||
self.loading_rides = True
|
||||
|
||||
# Get rides with related data
|
||||
rides_queryset = self.park.rides.select_related(
|
||||
'ride_model__manufacturer',
|
||||
'park'
|
||||
).prefetch_related(
|
||||
'photos'
|
||||
).order_by('name')
|
||||
|
||||
# Convert to list for caching compatibility
|
||||
self.rides = []
|
||||
for ride in rides_queryset:
|
||||
ride_data = {
|
||||
'id': ride.id,
|
||||
'name': ride.name,
|
||||
'slug': ride.slug,
|
||||
'category': ride.category,
|
||||
'category_display': ride.get_category_display(),
|
||||
'status': ride.status,
|
||||
'status_display': ride.get_status_display(),
|
||||
'average_rating': ride.average_rating,
|
||||
'url': ride.get_absolute_url(),
|
||||
'has_photos': ride.photos.exists(),
|
||||
'ride_model': {
|
||||
'name': ride.ride_model.name if ride.ride_model else None,
|
||||
'manufacturer': ride.ride_model.manufacturer.name if ride.ride_model and ride.ride_model.manufacturer else None,
|
||||
} if ride.ride_model else None
|
||||
}
|
||||
self.rides.append(ride_data)
|
||||
|
||||
except Exception as e:
|
||||
self.rides = []
|
||||
finally:
|
||||
self.loading_rides = False
|
||||
|
||||
def load_photos(self):
|
||||
"""Load park photos data."""
|
||||
if not self.park:
|
||||
self.photos = []
|
||||
return
|
||||
|
||||
try:
|
||||
self.loading_photos = True
|
||||
|
||||
# Get photos with related data
|
||||
photos_queryset = self.park.photos.select_related(
|
||||
'uploaded_by'
|
||||
).order_by('-created_at')
|
||||
|
||||
# Convert to list for caching compatibility
|
||||
self.photos = []
|
||||
for photo in photos_queryset:
|
||||
photo_data = {
|
||||
'id': photo.id,
|
||||
'image_url': photo.image.url if photo.image else None,
|
||||
'image_variants': getattr(photo.image, 'variants', []) if photo.image else [],
|
||||
'caption': photo.caption or '',
|
||||
'uploaded_by': photo.uploaded_by.username if photo.uploaded_by else 'Anonymous',
|
||||
'created_at': photo.created_at,
|
||||
'is_primary': getattr(photo, 'is_primary', False),
|
||||
}
|
||||
self.photos.append(photo_data)
|
||||
|
||||
except Exception as e:
|
||||
self.photos = []
|
||||
finally:
|
||||
self.loading_photos = False
|
||||
|
||||
def load_history(self):
|
||||
"""Load park history records."""
|
||||
if not self.park:
|
||||
self.history_records = []
|
||||
return
|
||||
|
||||
try:
|
||||
self.loading_history = True
|
||||
|
||||
# Get history records (using pghistory)
|
||||
history_queryset = self.park.history.select_related(
|
||||
'history_user'
|
||||
).order_by('-history_date')[:10] # Last 10 changes
|
||||
|
||||
# Convert to list for caching compatibility
|
||||
self.history_records = []
|
||||
for record in history_queryset:
|
||||
# Get changes from previous record
|
||||
changes = {}
|
||||
try:
|
||||
if hasattr(record, 'diff_against_previous'):
|
||||
diff = record.diff_against_previous()
|
||||
if diff:
|
||||
changes = {
|
||||
field: {
|
||||
'old': str(change.old) if change.old is not None else 'None',
|
||||
'new': str(change.new) if change.new is not None else 'None'
|
||||
}
|
||||
for field, change in diff.items()
|
||||
if field != 'updated_at' # Skip timestamp changes
|
||||
}
|
||||
except:
|
||||
changes = {}
|
||||
|
||||
history_data = {
|
||||
'id': record.history_id,
|
||||
'date': record.history_date,
|
||||
'user': record.history_user.username if record.history_user else 'System',
|
||||
'changes': changes,
|
||||
'has_changes': bool(changes)
|
||||
}
|
||||
self.history_records.append(history_data)
|
||||
|
||||
except Exception as e:
|
||||
self.history_records = []
|
||||
finally:
|
||||
self.loading_history = False
|
||||
|
||||
def load_map_data(self):
|
||||
"""Load map coordinates if location exists."""
|
||||
if not self.park:
|
||||
self.show_map = False
|
||||
return
|
||||
|
||||
try:
|
||||
location = self.park.location.first()
|
||||
if location and location.point:
|
||||
self.map_latitude = location.point.y
|
||||
self.map_longitude = location.point.x
|
||||
self.show_map = True
|
||||
else:
|
||||
self.show_map = False
|
||||
except:
|
||||
self.show_map = False
|
||||
|
||||
# UI Event Handlers
|
||||
def toggle_photo_modal(self):
|
||||
"""Toggle photo upload modal."""
|
||||
self.show_photo_modal = not self.show_photo_modal
|
||||
if self.show_photo_modal:
|
||||
self.upload_error = ""
|
||||
self.upload_success = ""
|
||||
|
||||
def close_photo_modal(self):
|
||||
"""Close photo upload modal."""
|
||||
self.show_photo_modal = False
|
||||
self.upload_error = ""
|
||||
self.upload_success = ""
|
||||
|
||||
def toggle_all_rides(self):
|
||||
"""Toggle between showing limited rides vs all rides."""
|
||||
self.show_all_rides = not self.show_all_rides
|
||||
|
||||
def refresh_photos(self):
|
||||
"""Refresh photos after upload."""
|
||||
self.load_photos()
|
||||
self.upload_success = "Photo uploaded successfully!"
|
||||
|
||||
# Auto-hide success message after 3 seconds
|
||||
# Note: In a real implementation, you might use JavaScript for this
|
||||
|
||||
def refresh_data(self):
|
||||
"""Refresh all park data."""
|
||||
self.load_park_data()
|
||||
|
||||
# Computed Properties
|
||||
@property
|
||||
def visible_rides(self) -> List[Dict[str, Any]]:
|
||||
"""Get rides to display (limited or all)."""
|
||||
if self.show_all_rides:
|
||||
return self.rides
|
||||
return self.rides[:6] # Show first 6 rides
|
||||
|
||||
@property
|
||||
def has_more_rides(self) -> bool:
|
||||
"""Check if there are more rides to show."""
|
||||
return len(self.rides) > 6
|
||||
|
||||
@property
|
||||
def park_stats(self) -> Dict[str, Any]:
|
||||
"""Get park statistics for display."""
|
||||
if not self.park:
|
||||
return {}
|
||||
|
||||
return {
|
||||
'total_rides': self.park.ride_count or len(self.rides),
|
||||
'coaster_count': self.park.coaster_count or 0,
|
||||
'average_rating': self.park.average_rating,
|
||||
'status': self.park.get_status_display() if self.park else '',
|
||||
'opening_date': self.park.opening_date if self.park else None,
|
||||
'website': self.park.website if self.park else None,
|
||||
'operator': {
|
||||
'name': self.park.operator.name if self.park and self.park.operator else None,
|
||||
'slug': self.park.operator.slug if self.park and self.park.operator else None,
|
||||
},
|
||||
'property_owner': {
|
||||
'name': self.park.property_owner.name if self.park and self.park.property_owner else None,
|
||||
'slug': self.park.property_owner.slug if self.park and self.park.property_owner else None,
|
||||
} if self.park and self.park.property_owner and self.park.property_owner != self.park.operator else None
|
||||
}
|
||||
|
||||
@property
|
||||
def can_upload_photos(self) -> bool:
|
||||
"""Check if user can upload photos."""
|
||||
if isinstance(self.request.user, AnonymousUser):
|
||||
return False
|
||||
return self.request.user.has_perm('media.add_photo')
|
||||
|
||||
@property
|
||||
def formatted_location(self) -> str:
|
||||
"""Get formatted location string."""
|
||||
if not self.park:
|
||||
return ""
|
||||
|
||||
try:
|
||||
location = self.park.location.first()
|
||||
if location:
|
||||
parts = []
|
||||
if location.city:
|
||||
parts.append(location.city)
|
||||
if location.state:
|
||||
parts.append(location.state)
|
||||
if location.country:
|
||||
parts.append(location.country)
|
||||
return ", ".join(parts)
|
||||
except:
|
||||
pass
|
||||
|
||||
return ""
|
||||
136
backend/apps/parks/components/park_search.py
Normal file
136
backend/apps/parks/components/park_search.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from django_unicorn.components import UnicornView
|
||||
from django.db.models import Q
|
||||
from django.core.paginator import Paginator
|
||||
from apps.parks.models import Park
|
||||
|
||||
|
||||
class ParkSearchView(UnicornView):
|
||||
"""
|
||||
Reactive park search component that replaces HTMX functionality.
|
||||
Provides real-time search, filtering, and view mode switching.
|
||||
"""
|
||||
|
||||
# Search and filter state
|
||||
search_query: str = ""
|
||||
view_mode: str = "grid" # "grid" or "list"
|
||||
|
||||
# Results state
|
||||
parks = [] # Use list instead of QuerySet for caching compatibility
|
||||
total_results: int = 0
|
||||
page: int = 1
|
||||
per_page: int = 12
|
||||
|
||||
# Loading state
|
||||
is_loading: bool = False
|
||||
|
||||
def mount(self):
|
||||
"""Initialize component with all parks"""
|
||||
self.load_parks()
|
||||
|
||||
def load_parks(self):
|
||||
"""Load parks based on current search and filters"""
|
||||
self.is_loading = True
|
||||
|
||||
# Start with all parks
|
||||
queryset = Park.objects.select_related(
|
||||
'operator', 'property_owner', 'location'
|
||||
).prefetch_related('photos')
|
||||
|
||||
# Apply search filter
|
||||
if self.search_query.strip():
|
||||
search_terms = self.search_query.strip().split()
|
||||
search_q = Q()
|
||||
|
||||
for term in search_terms:
|
||||
term_q = (
|
||||
Q(name__icontains=term) |
|
||||
Q(description__icontains=term) |
|
||||
Q(location__city__icontains=term) |
|
||||
Q(location__state__icontains=term) |
|
||||
Q(location__country__icontains=term) |
|
||||
Q(operator__name__icontains=term)
|
||||
)
|
||||
search_q &= term_q
|
||||
|
||||
queryset = queryset.filter(search_q)
|
||||
|
||||
# Order by name
|
||||
queryset = queryset.order_by('name')
|
||||
|
||||
# Get total count
|
||||
self.total_results = queryset.count()
|
||||
|
||||
# Apply pagination
|
||||
paginator = Paginator(queryset, self.per_page)
|
||||
page_obj = paginator.get_page(self.page)
|
||||
|
||||
# Convert to list for caching compatibility
|
||||
self.parks = list(page_obj.object_list)
|
||||
self.is_loading = False
|
||||
|
||||
def updated_search_query(self, query):
|
||||
"""Called when search query changes"""
|
||||
self.search_query = query
|
||||
self.page = 1 # Reset to first page
|
||||
self.load_parks()
|
||||
|
||||
def set_view_mode(self, mode):
|
||||
"""Switch between grid and list view modes"""
|
||||
if mode in ['grid', 'list']:
|
||||
self.view_mode = mode
|
||||
|
||||
def clear_search(self):
|
||||
"""Clear search query and reload all parks"""
|
||||
self.search_query = ""
|
||||
self.page = 1
|
||||
self.load_parks()
|
||||
|
||||
def next_page(self):
|
||||
"""Go to next page"""
|
||||
if self.has_next_page():
|
||||
self.page += 1
|
||||
self.load_parks()
|
||||
|
||||
def previous_page(self):
|
||||
"""Go to previous page"""
|
||||
if self.has_previous_page():
|
||||
self.page -= 1
|
||||
self.load_parks()
|
||||
|
||||
def go_to_page(self, page_num):
|
||||
"""Go to specific page"""
|
||||
if 1 <= page_num <= self.total_pages():
|
||||
self.page = page_num
|
||||
self.load_parks()
|
||||
|
||||
def has_next_page(self):
|
||||
"""Check if there's a next page"""
|
||||
return self.page < self.total_pages()
|
||||
|
||||
def has_previous_page(self):
|
||||
"""Check if there's a previous page"""
|
||||
return self.page > 1
|
||||
|
||||
def total_pages(self):
|
||||
"""Calculate total number of pages"""
|
||||
if self.total_results == 0:
|
||||
return 1
|
||||
return (self.total_results + self.per_page - 1) // self.per_page
|
||||
|
||||
def get_page_range(self):
|
||||
"""Get range of page numbers for pagination"""
|
||||
total = self.total_pages()
|
||||
current = self.page
|
||||
|
||||
# Show 5 pages around current page
|
||||
start = max(1, current - 2)
|
||||
end = min(total, current + 2)
|
||||
|
||||
# Adjust if we're near the beginning or end
|
||||
if end - start < 4:
|
||||
if start == 1:
|
||||
end = min(total, start + 4)
|
||||
else:
|
||||
start = max(1, end - 4)
|
||||
|
||||
return list(range(start, end + 1))
|
||||
@@ -0,0 +1,70 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-31 22:40
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0013_remove_park_insert_insert_remove_park_update_update_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="park",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="park",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="park",
|
||||
name="timezone",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Timezone identifier (e.g., 'America/New_York', 'Europe/London')",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parkevent",
|
||||
name="timezone",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Timezone identifier (e.g., 'America/New_York', 'Europe/London')",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="park",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
|
||||
hash="ee644ec1233d6d0f1ab71b07454fb1479a2940cf",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_66883",
|
||||
table="parks_park",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="park",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
|
||||
hash="d855e6255fb80b0ec6d375045aa520114624c569",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_19f56",
|
||||
table="parks_park",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -36,6 +36,23 @@ class Company(TrackedModel):
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""Generate the frontend URL for this company based on its roles."""
|
||||
from config.django import base as settings
|
||||
|
||||
if "OPERATOR" in self.roles:
|
||||
return f"{settings.FRONTEND_DOMAIN}/parks/operators/{self.slug}/"
|
||||
elif "PROPERTY_OWNER" in self.roles:
|
||||
return f"{settings.FRONTEND_DOMAIN}/parks/owners/{self.slug}/"
|
||||
elif "MANUFACTURER" in self.roles:
|
||||
return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{self.slug}/"
|
||||
elif "DESIGNER" in self.roles:
|
||||
return f"{settings.FRONTEND_DOMAIN}/rides/designers/{self.slug}/"
|
||||
else:
|
||||
# Default fallback
|
||||
return f"{settings.FRONTEND_DOMAIN}/companies/{self.slug}/"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@@ -70,6 +70,11 @@ class Park(TrackedModel):
|
||||
max_digits=10, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
website = models.URLField(blank=True)
|
||||
timezone = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text="Timezone identifier (e.g., 'America/New_York', 'Europe/London')"
|
||||
)
|
||||
|
||||
# Statistics
|
||||
average_rating = models.DecimalField(
|
||||
|
||||
388
backend/apps/parks/templates/unicorn/park-detail.html
Normal file
388
backend/apps/parks/templates/unicorn/park-detail.html
Normal file
@@ -0,0 +1,388 @@
|
||||
{% load static %}
|
||||
|
||||
<!-- Loading State -->
|
||||
<div unicorn:loading.class="opacity-50 pointer-events-none">
|
||||
{% if not park %}
|
||||
<!-- Park Not Found -->
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<div class="p-8 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<div class="mb-4">
|
||||
<i class="text-6xl text-gray-400 fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<h1 class="mb-2 text-2xl font-bold text-gray-900 dark:text-white">Park Not Found</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">The park you're looking for doesn't exist or has been removed.</p>
|
||||
<div class="mt-6">
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<i class="mr-2 fas fa-arrow-left"></i>
|
||||
Back to Parks
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Park Detail Content -->
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Action Buttons - Above header -->
|
||||
{% if can_upload_photos %}
|
||||
<div class="mb-4 text-right">
|
||||
<button unicorn:click="toggle_photo_modal"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
|
||||
<i class="mr-2 fas fa-camera"></i>
|
||||
Upload Photos
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Park Header -->
|
||||
<div class="p-compact mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white lg:text-4xl">{{ park.name }}</h1>
|
||||
{% if formatted_location %}
|
||||
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
<p>{{ formatted_location }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||
<span class="status-badge text-sm font-medium py-1 px-3 {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.average_rating %}
|
||||
<span class="flex items-center px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ park.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal Stats Bar -->
|
||||
<div class="grid-stats mb-6">
|
||||
<!-- Operator - Priority Card (First Position) -->
|
||||
{% if park_stats.operator.name %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats card-stats-priority">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Operator</dt>
|
||||
<dd class="mt-1">
|
||||
<span class="text-sm font-bold text-sky-900 dark:text-sky-400">
|
||||
{{ park_stats.operator.name }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Property Owner (if different from operator) -->
|
||||
{% if park_stats.property_owner %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Property Owner</dt>
|
||||
<dd class="mt-1">
|
||||
<span class="text-sm font-bold text-sky-900 dark:text-sky-400">
|
||||
{{ park_stats.property_owner.name }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Total Rides -->
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats transition-transform hover:scale-[1.02]">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Total Rides</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400">{{ park_stats.total_rides|default:"N/A" }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roller Coasters -->
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Roller Coasters</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400">{{ park_stats.coaster_count|default:"N/A" }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Status</dt>
|
||||
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park_stats.status }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opened Date -->
|
||||
{% if park_stats.opening_date %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Opened</dt>
|
||||
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park_stats.opening_date }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Website -->
|
||||
{% if park_stats.website %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Website</dt>
|
||||
<dd class="mt-1">
|
||||
<a href="{{ park_stats.website }}"
|
||||
class="inline-flex items-center text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
Visit
|
||||
<i class="ml-1 text-xs fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Photos Section -->
|
||||
{% if photos %}
|
||||
<div class="p-optimized mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
|
||||
{% if can_upload_photos %}
|
||||
<button unicorn:click="toggle_photo_modal"
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<i class="mr-1 fas fa-plus"></i>
|
||||
Add Photos
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Loading State for Photos -->
|
||||
<div unicorn:loading.class.remove="hidden" class="hidden">
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span class="ml-2 text-gray-600 dark:text-gray-400">Loading photos...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos Grid -->
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
{% for photo in photos %}
|
||||
<div class="relative group">
|
||||
<img src="{{ photo.image_url }}"
|
||||
alt="{{ photo.caption|default:'Park photo' }}"
|
||||
class="object-cover w-full h-32 rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||
loading="lazy">
|
||||
{% if photo.caption %}
|
||||
<div class="absolute bottom-0 left-0 right-0 p-2 text-xs text-white bg-black bg-opacity-50 rounded-b-lg">
|
||||
{{ photo.caption|truncatechars:50 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Column - Description and Rides -->
|
||||
<div class="lg:col-span-2">
|
||||
{% if park.description %}
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{{ park.description|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rides and Attractions -->
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading State for Rides -->
|
||||
<div unicorn:loading.class.remove="hidden" class="hidden">
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span class="ml-2 text-gray-600 dark:text-gray-400">Loading rides...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if visible_rides %}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{% for ride in visible_rides %}
|
||||
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<a href="{{ ride.url }}" class="block">
|
||||
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">{{ ride.name }}</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
|
||||
{{ ride.category_display }}
|
||||
</span>
|
||||
{% if ride.average_rating %}
|
||||
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ ride.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if ride.ride_model %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ ride.ride_model.manufacturer }} {{ ride.ride_model.name }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Show More/Less Button -->
|
||||
{% if has_more_rides %}
|
||||
<div class="mt-4 text-center">
|
||||
<button unicorn:click="toggle_all_rides"
|
||||
class="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 dark:bg-blue-900 dark:text-blue-300 dark:border-blue-700 dark:hover:bg-blue-800">
|
||||
{% if show_all_rides %}
|
||||
<i class="mr-1 fas fa-chevron-up"></i>
|
||||
Show Less
|
||||
{% else %}
|
||||
<i class="mr-1 fas fa-chevron-down"></i>
|
||||
Show All {{ rides|length }} Rides
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides or attractions listed yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Map and Additional Info -->
|
||||
<div class="lg:col-span-1">
|
||||
<!-- Location Map -->
|
||||
{% if show_map %}
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Location</h2>
|
||||
<div class="relative rounded-lg bg-gray-200 dark:bg-gray-700" style="height: 200px;">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<i class="text-4xl text-gray-400 fas fa-map-marker-alt"></i>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ formatted_location }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Lat: {{ map_latitude|floatformat:4 }}, Lng: {{ map_longitude|floatformat:4 }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- History Panel -->
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
|
||||
|
||||
<!-- Loading State for History -->
|
||||
<div unicorn:loading.class.remove="hidden" class="hidden">
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Loading history...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% for record in history_records %}
|
||||
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ record.date|date:"M d, Y H:i" }}
|
||||
by {{ record.user }}
|
||||
</div>
|
||||
{% if record.has_changes %}
|
||||
<div class="mt-2">
|
||||
{% for field, change in record.changes.items %}
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{{ field|title }}:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{{ change.old }}</span>
|
||||
<span class="mx-1">→</span>
|
||||
<span class="text-green-600 dark:text-green-400">{{ change.new }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-gray-500 dark:text-gray-400">No history available.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Upload Modal -->
|
||||
{% if show_photo_modal and can_upload_photos %}
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50"
|
||||
unicorn:click.self="close_photo_modal">
|
||||
<div class="w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Upload Photos</h3>
|
||||
<button unicorn:click="close_photo_modal"
|
||||
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<i class="text-xl fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload Success Message -->
|
||||
{% if upload_success %}
|
||||
<div class="p-4 mb-4 text-green-800 bg-green-100 border border-green-200 rounded-md dark:bg-green-900 dark:text-green-200 dark:border-green-700">
|
||||
<div class="flex items-center">
|
||||
<i class="mr-2 fas fa-check-circle"></i>
|
||||
{{ upload_success }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Upload Error Message -->
|
||||
{% if upload_error %}
|
||||
<div class="p-4 mb-4 text-red-800 bg-red-100 border border-red-200 rounded-md dark:bg-red-900 dark:text-red-200 dark:border-red-700">
|
||||
<div class="flex items-center">
|
||||
<i class="mr-2 fas fa-exclamation-circle"></i>
|
||||
{{ upload_error }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Photo Upload Form Placeholder -->
|
||||
<div class="p-8 text-center border-2 border-gray-300 border-dashed rounded-lg dark:border-gray-600">
|
||||
<i class="text-4xl text-gray-400 fas fa-cloud-upload-alt"></i>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Photo upload functionality will be integrated here
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500">
|
||||
This would connect to the existing photo upload system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6 space-x-3">
|
||||
<button unicorn:click="close_photo_modal"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
<button unicorn:click="refresh_photos"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Refresh Photos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
250
backend/apps/parks/templates/unicorn/park-search.html
Normal file
250
backend/apps/parks/templates/unicorn/park-search.html
Normal file
@@ -0,0 +1,250 @@
|
||||
<div class="park-search-component">
|
||||
<!-- Search and Controls Bar -->
|
||||
<div class="bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<!-- Search Section -->
|
||||
<div class="flex-1 max-w-2xl">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" 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>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
unicorn:model.debounce-300="search_query"
|
||||
placeholder="Search parks by name, location, or features..."
|
||||
class="block w-full pl-10 pr-10 py-2 border border-gray-600 rounded-md leading-5 bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
|
||||
<!-- Clear Search Button -->
|
||||
{% if search_query %}
|
||||
<button
|
||||
unicorn:click="clear_search"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-white"
|
||||
title="Clear search"
|
||||
>
|
||||
<svg class="h-5 w-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 %}
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
{% if is_loading %}
|
||||
<div class="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<svg class="animate-spin h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Count and View Controls -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Results Count -->
|
||||
<div class="text-gray-300 text-sm whitespace-nowrap">
|
||||
<span class="font-medium">Parks</span>
|
||||
{% if total_results %}
|
||||
<span class="text-gray-400">({{ total_results }} found)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="flex bg-gray-700 rounded-lg p-1">
|
||||
<!-- Grid View Button -->
|
||||
<button
|
||||
type="button"
|
||||
unicorn:click="set_view_mode('grid')"
|
||||
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'grid' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
|
||||
title="Grid View"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- List View Button -->
|
||||
<button
|
||||
type="button"
|
||||
unicorn:click="set_view_mode('list')"
|
||||
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'list' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
|
||||
title="List View"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div class="parks-results">
|
||||
{% if view_mode == 'list' %}
|
||||
<!-- Parks List View -->
|
||||
<div class="space-y-4">
|
||||
{% for park in parks %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 overflow-hidden">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
{% if park.photos.exists %}
|
||||
<div class="md:w-48 md:flex-shrink-0">
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-48 md:h-full object-cover">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-1 p-6">
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
{% if park.location %}
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-3">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
{% spaceless %}
|
||||
{% if park.location.city %}{{ park.location.city }}{% endif %}{% if park.location.city and park.location.state %}, {% endif %}{% if park.location.state %}{{ park.location.state }}{% endif %}{% if park.location.country and park.location.state or park.location.city %}, {% endif %}{% if park.location.country %}{{ park.location.country }}{% endif %}
|
||||
{% endspaceless %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if park.operator %}
|
||||
<p class="text-blue-600 dark:text-blue-400 mb-3">
|
||||
{{ park.operator.name }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-col items-start md:items-end gap-2 mt-4 md:mt-0">
|
||||
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.average_rating %}
|
||||
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||
{{ park.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="py-8 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{% if search_query %}
|
||||
No parks found matching "{{ search_query }}".
|
||||
{% else %}
|
||||
No parks found.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Parks Grid View -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for park in parks %}
|
||||
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
{% if park.photos.exists %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="object-cover w-full h-48">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
<h2 class="mb-2 text-xl font-bold">
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
{% if park.location %}
|
||||
<p class="mb-3 text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
{% spaceless %}
|
||||
{% if park.location.city %}{{ park.location.city }}{% endif %}{% if park.location.city and park.location.state %}, {% endif %}{% if park.location.state %}{{ park.location.state }}{% endif %}{% if park.location.country and park.location.state or park.location.city %}, {% endif %}{% if park.location.country %}{{ park.location.country }}{% endif %}
|
||||
{% endspaceless %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.average_rating %}
|
||||
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||
{{ park.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if park.operator %}
|
||||
<div class="mt-4 text-sm text-blue-600 dark:text-blue-400">
|
||||
{{ park.operator.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-full py-8 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{% if search_query %}
|
||||
No parks found matching "{{ search_query }}".
|
||||
{% else %}
|
||||
No parks found.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_results > per_page %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="inline-flex rounded-md shadow-xs">
|
||||
{% if has_previous_page %}
|
||||
<button
|
||||
unicorn:click="go_to_page(1)"
|
||||
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>« First</button>
|
||||
<button
|
||||
unicorn:click="previous_page"
|
||||
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>Previous</button>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||
Page {{ page }} of {{ total_pages }}
|
||||
</span>
|
||||
|
||||
{% if has_next_page %}
|
||||
<button
|
||||
unicorn:click="next_page"
|
||||
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>Next</button>
|
||||
<button
|
||||
unicorn:click="go_to_page({{ total_pages }})"
|
||||
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>Last »</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
350
backend/apps/rides/components/ride_list.py
Normal file
350
backend/apps/rides/components/ride_list.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Comprehensive Ride List Component for Django Unicorn
|
||||
|
||||
This component replaces the complex HTMX-based ride_list.html template with a reactive
|
||||
Django Unicorn implementation. It integrates all 5 core components established in Phase 1:
|
||||
- SearchFormView for debounced search
|
||||
- FilterSidebarView for advanced filtering
|
||||
- PaginationView for pagination
|
||||
- LoadingStatesView for loading states
|
||||
- ModalManagerView for modals
|
||||
|
||||
Key Features:
|
||||
- 8 filter categories with 50+ filter options
|
||||
- Mobile-responsive design with overlay
|
||||
- Debounced search (300ms)
|
||||
- Server-side state management
|
||||
- QuerySet caching compatibility
|
||||
- Performance optimizations
|
||||
"""
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django_unicorn.components import UnicornView
|
||||
from django.core.paginator import Paginator
|
||||
from django.shortcuts import get_object_or_404
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from apps.rides.models.rides import Ride
|
||||
from apps.rides.forms.search import MasterFilterForm
|
||||
from apps.rides.services.search import RideSearchService
|
||||
from apps.parks.models import Park
|
||||
|
||||
|
||||
class RideListView(UnicornView):
|
||||
"""
|
||||
Comprehensive ride list component with advanced filtering and search.
|
||||
|
||||
Replaces the complex HTMX template with reactive Django Unicorn implementation.
|
||||
Integrates all core components for a unified user experience.
|
||||
"""
|
||||
|
||||
# Core state management
|
||||
search_query: str = ""
|
||||
filters: Dict[str, Any] = {}
|
||||
rides: List[Ride] = []
|
||||
|
||||
# Pagination state
|
||||
current_page: int = 1
|
||||
page_size: int = 24
|
||||
total_count: int = 0
|
||||
total_pages: int = 0
|
||||
|
||||
# Sorting state
|
||||
sort_by: str = "name"
|
||||
sort_order: str = "asc"
|
||||
|
||||
# UI state
|
||||
is_loading: bool = False
|
||||
mobile_filters_open: bool = False
|
||||
has_filters: bool = False
|
||||
|
||||
# Context state
|
||||
park: Optional[Park] = None
|
||||
park_slug: Optional[str] = None
|
||||
|
||||
# Filter form instance
|
||||
filter_form: Optional[MasterFilterForm] = None
|
||||
|
||||
def mount(self):
|
||||
"""Initialize component state on mount."""
|
||||
# Get park context if available
|
||||
if hasattr(self, 'kwargs') and 'park_slug' in self.kwargs:
|
||||
self.park_slug = self.kwargs['park_slug']
|
||||
self.park = get_object_or_404(Park, slug=self.park_slug)
|
||||
|
||||
# Initialize filter form
|
||||
self.filter_form = MasterFilterForm()
|
||||
|
||||
# Load initial rides
|
||||
self.load_rides()
|
||||
|
||||
def load_rides(self):
|
||||
"""
|
||||
Load rides based on current search, filters, and pagination state.
|
||||
|
||||
Uses RideSearchService for advanced filtering and converts QuerySet to list
|
||||
for Django Unicorn caching compatibility.
|
||||
"""
|
||||
self.is_loading = True
|
||||
|
||||
try:
|
||||
# Initialize search service
|
||||
search_service = RideSearchService()
|
||||
|
||||
# Prepare filter data for form validation
|
||||
filter_data = self.filters.copy()
|
||||
if self.search_query:
|
||||
filter_data['global_search'] = self.search_query
|
||||
if self.sort_by:
|
||||
filter_data['sort_by'] = f"{self.sort_by}_{self.sort_order}"
|
||||
|
||||
# Create and validate filter form
|
||||
self.filter_form = MasterFilterForm(filter_data)
|
||||
|
||||
if self.filter_form.is_valid():
|
||||
# Use advanced search service with pagination
|
||||
search_results = search_service.search_and_filter(
|
||||
filters=self.filter_form.get_filter_dict(),
|
||||
sort_by=f"{self.sort_by}_{self.sort_order}",
|
||||
page=self.current_page,
|
||||
page_size=self.page_size
|
||||
)
|
||||
|
||||
# Extract results from search service response
|
||||
# CRITICAL: Results are already converted to list by search service
|
||||
self.rides = search_results['results']
|
||||
self.total_count = search_results['pagination']['total_count']
|
||||
self.total_pages = search_results['pagination']['total_pages']
|
||||
|
||||
else:
|
||||
# Fallback to basic queryset with manual pagination
|
||||
queryset = self._get_base_queryset()
|
||||
|
||||
# Apply basic search if present
|
||||
if self.search_query:
|
||||
queryset = queryset.filter(name__icontains=self.search_query)
|
||||
|
||||
# Apply sorting
|
||||
queryset = self._apply_sorting(queryset)
|
||||
|
||||
# Get total count before pagination
|
||||
self.total_count = queryset.count()
|
||||
|
||||
# Apply pagination
|
||||
paginator = Paginator(queryset, self.page_size)
|
||||
self.total_pages = paginator.num_pages
|
||||
|
||||
# Ensure current page is valid
|
||||
if self.current_page > self.total_pages and self.total_pages > 0:
|
||||
self.current_page = self.total_pages
|
||||
elif self.current_page < 1:
|
||||
self.current_page = 1
|
||||
|
||||
# Get page data
|
||||
if self.total_pages > 0:
|
||||
page_obj = paginator.get_page(self.current_page)
|
||||
# CRITICAL: Convert QuerySet to list for Django Unicorn caching
|
||||
self.rides = list(page_obj.object_list)
|
||||
else:
|
||||
self.rides = []
|
||||
|
||||
# Update filter state
|
||||
self.has_filters = bool(self.search_query or self.filters)
|
||||
|
||||
except Exception as e:
|
||||
# Handle errors gracefully
|
||||
self.rides = []
|
||||
self.total_count = 0
|
||||
self.total_pages = 0
|
||||
print(f"Error loading rides: {e}") # Log error for debugging
|
||||
|
||||
finally:
|
||||
self.is_loading = False
|
||||
|
||||
def _get_base_queryset(self) -> QuerySet:
|
||||
"""Get base queryset with optimized database queries."""
|
||||
queryset = (
|
||||
Ride.objects.all()
|
||||
.select_related(
|
||||
'park',
|
||||
'ride_model',
|
||||
'ride_model__manufacturer',
|
||||
'manufacturer',
|
||||
'designer'
|
||||
)
|
||||
.prefetch_related('photos')
|
||||
)
|
||||
|
||||
# Apply park filter if in park context
|
||||
if self.park:
|
||||
queryset = queryset.filter(park=self.park)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_sorting(self, queryset: QuerySet) -> QuerySet:
|
||||
"""Apply sorting to queryset based on current sort state."""
|
||||
sort_field = self.sort_by
|
||||
|
||||
# Map sort fields to actual model fields
|
||||
sort_mapping = {
|
||||
'name': 'name',
|
||||
'opening_date': 'opened_date',
|
||||
'height': 'height',
|
||||
'speed': 'max_speed',
|
||||
'capacity': 'capacity_per_hour',
|
||||
'rating': 'average_rating',
|
||||
}
|
||||
|
||||
if sort_field in sort_mapping:
|
||||
field = sort_mapping[sort_field]
|
||||
if self.sort_order == 'desc':
|
||||
field = f'-{field}'
|
||||
queryset = queryset.order_by(field)
|
||||
|
||||
return queryset
|
||||
|
||||
# Search callback methods
|
||||
def on_search(self, query: str):
|
||||
"""Handle search query changes from SearchFormView component."""
|
||||
self.search_query = query.strip()
|
||||
self.current_page = 1 # Reset to first page on new search
|
||||
self.load_rides()
|
||||
|
||||
def clear_search(self):
|
||||
"""Clear search query."""
|
||||
self.search_query = ""
|
||||
self.current_page = 1
|
||||
self.load_rides()
|
||||
|
||||
# Filter callback methods
|
||||
def on_filters_changed(self, filters: Dict[str, Any]):
|
||||
"""Handle filter changes from FilterSidebarView component."""
|
||||
self.filters = filters
|
||||
self.current_page = 1 # Reset to first page on filter change
|
||||
self.load_rides()
|
||||
|
||||
def clear_filters(self):
|
||||
"""Clear all filters."""
|
||||
self.filters = {}
|
||||
self.current_page = 1
|
||||
self.load_rides()
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear both search and filters."""
|
||||
self.search_query = ""
|
||||
self.filters = {}
|
||||
self.current_page = 1
|
||||
self.load_rides()
|
||||
|
||||
# Pagination callback methods
|
||||
def on_page_changed(self, page: int):
|
||||
"""Handle page changes from PaginationView component."""
|
||||
self.current_page = page
|
||||
self.load_rides()
|
||||
|
||||
def go_to_page(self, page: int):
|
||||
"""Navigate to specific page."""
|
||||
if 1 <= page <= self.total_pages:
|
||||
self.current_page = page
|
||||
self.load_rides()
|
||||
|
||||
def next_page(self):
|
||||
"""Navigate to next page."""
|
||||
if self.current_page < self.total_pages:
|
||||
self.current_page += 1
|
||||
self.load_rides()
|
||||
|
||||
def previous_page(self):
|
||||
"""Navigate to previous page."""
|
||||
if self.current_page > 1:
|
||||
self.current_page -= 1
|
||||
self.load_rides()
|
||||
|
||||
# Sorting callback methods
|
||||
def on_sort_changed(self, sort_by: str, sort_order: str = "asc"):
|
||||
"""Handle sorting changes."""
|
||||
self.sort_by = sort_by
|
||||
self.sort_order = sort_order
|
||||
self.load_rides()
|
||||
|
||||
def set_sort(self, sort_by: str):
|
||||
"""Set sort field and toggle order if same field."""
|
||||
if self.sort_by == sort_by:
|
||||
# Toggle order if same field
|
||||
self.sort_order = "desc" if self.sort_order == "asc" else "asc"
|
||||
else:
|
||||
# New field, default to ascending
|
||||
self.sort_by = sort_by
|
||||
self.sort_order = "asc"
|
||||
|
||||
self.load_rides()
|
||||
|
||||
# Mobile UI methods
|
||||
def toggle_mobile_filters(self):
|
||||
"""Toggle mobile filter sidebar."""
|
||||
self.mobile_filters_open = not self.mobile_filters_open
|
||||
|
||||
def close_mobile_filters(self):
|
||||
"""Close mobile filter sidebar."""
|
||||
self.mobile_filters_open = False
|
||||
|
||||
# Utility methods
|
||||
def get_active_filter_count(self) -> int:
|
||||
"""Get count of active filters for display."""
|
||||
count = 0
|
||||
if self.search_query:
|
||||
count += 1
|
||||
if self.filter_form and self.filter_form.is_valid():
|
||||
count += len([v for v in self.filter_form.cleaned_data.values() if v])
|
||||
return count
|
||||
|
||||
def get_filter_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary of active filters for display."""
|
||||
if self.filter_form and self.filter_form.is_valid():
|
||||
return self.filter_form.get_filter_summary()
|
||||
return {}
|
||||
|
||||
def get_results_text(self) -> str:
|
||||
"""Get results summary text."""
|
||||
if self.total_count == 0:
|
||||
return "No rides found"
|
||||
elif self.has_filters:
|
||||
total_rides = self._get_base_queryset().count()
|
||||
return f"{self.total_count} of {total_rides} rides"
|
||||
else:
|
||||
return f"All {self.total_count} rides"
|
||||
|
||||
def get_page_range(self) -> List[int]:
|
||||
"""Get page range for pagination display."""
|
||||
if self.total_pages <= 7:
|
||||
return list(range(1, self.total_pages + 1))
|
||||
|
||||
# Smart page range calculation
|
||||
start = max(1, self.current_page - 3)
|
||||
end = min(self.total_pages, self.current_page + 3)
|
||||
|
||||
# Adjust if we're near the beginning or end
|
||||
if end - start < 6:
|
||||
if start == 1:
|
||||
end = min(self.total_pages, start + 6)
|
||||
else:
|
||||
start = max(1, end - 6)
|
||||
|
||||
return list(range(start, end + 1))
|
||||
|
||||
# Template context methods
|
||||
def get_context_data(self) -> Dict[str, Any]:
|
||||
"""Get additional context data for template."""
|
||||
return {
|
||||
'park': self.park,
|
||||
'park_slug': self.park_slug,
|
||||
'filter_form': self.filter_form,
|
||||
'active_filter_count': self.get_active_filter_count(),
|
||||
'filter_summary': self.get_filter_summary(),
|
||||
'results_text': self.get_results_text(),
|
||||
'page_range': self.get_page_range(),
|
||||
'has_previous': self.current_page > 1,
|
||||
'has_next': self.current_page < self.total_pages,
|
||||
'start_index': (self.current_page - 1) * self.page_size + 1 if self.rides else 0,
|
||||
'end_index': min(self.current_page * self.page_size, self.total_count),
|
||||
}
|
||||
379
backend/apps/rides/templates/unicorn/ride-list.html
Normal file
379
backend/apps/rides/templates/unicorn/ride-list.html
Normal file
@@ -0,0 +1,379 @@
|
||||
{% load unicorn %}
|
||||
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Mobile filter overlay -->
|
||||
<div class="{% if mobile_filters_open %}block{% else %}hidden{% endif %} lg:hidden fixed inset-0 bg-black bg-opacity-50 z-40"
|
||||
unicorn:click="close_mobile_filters"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-full px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Title and breadcrumb -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{% if park %}
|
||||
<i class="fas fa-map-marker-alt mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
{{ park.name }} - Rides
|
||||
{% else %}
|
||||
<i class="fas fa-rocket mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
All Rides
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<!-- Mobile filter toggle -->
|
||||
<button unicorn:click="toggle_mobile_filters"
|
||||
class="lg:hidden inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Filters
|
||||
{% if get_active_filter_count > 0 %}
|
||||
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ get_active_filter_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results summary -->
|
||||
<div class="hidden sm:flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
<i class="fas fa-list mr-1"></i>
|
||||
{{ get_results_text }}
|
||||
</span>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
{% if is_loading %}
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-spinner fa-spin text-blue-600"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex">
|
||||
<!-- Desktop filter sidebar -->
|
||||
<div class="hidden lg:block">
|
||||
{% include "rides/partials/ride_filter_sidebar.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile filter panel -->
|
||||
<div class="{% if mobile_filters_open %}translate-x-0{% else %}-translate-x-full{% endif %} lg:hidden fixed left-0 top-0 bottom-0 w-full max-w-sm bg-white dark:bg-gray-900 z-50 transform transition-transform duration-300 ease-in-out">
|
||||
<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">Filters</h3>
|
||||
<button unicorn:click="close_mobile_filters" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% include "rides/partials/ride_filter_sidebar.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Results area -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="p-4 lg:p-6">
|
||||
<!-- Active filters display -->
|
||||
{% if has_filters %}
|
||||
<div class="mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Active Filters ({{ get_active_filter_count }})
|
||||
</h3>
|
||||
<button unicorn:click="clear_all"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 transition-colors">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if search_query %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
|
||||
Search: "{{ search_query }}"
|
||||
<button unicorn:click="clear_search" class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% for category, filter_list in get_filter_summary.items %}
|
||||
{% for filter_item in filter_list %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
|
||||
{{ filter_item.label }}: {{ filter_item.value }}
|
||||
<button class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="mb-6">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
unicorn:model.debounce-300="search_query"
|
||||
placeholder="Search rides, parks, manufacturers..."
|
||||
class="w-full px-4 py-3 pl-10 pr-4 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400"></i>
|
||||
</div>
|
||||
{% if search_query %}
|
||||
<button unicorn:click="clear_search"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results header with sorting -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
|
||||
<div class="mb-4 sm:mb-0">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ get_results_text }}
|
||||
{% if park %}
|
||||
at {{ park.name }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if search_query %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
<i class="fas fa-search mr-1"></i>
|
||||
Searching for: "{{ search_query }}"
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Sort options -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
|
||||
<select unicorn:model="sort_by"
|
||||
unicorn:change="load_rides"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm">
|
||||
<option value="name">Name (A-Z)</option>
|
||||
<option value="opening_date">Opening Date</option>
|
||||
<option value="height">Height</option>
|
||||
<option value="speed">Speed</option>
|
||||
<option value="capacity">Capacity</option>
|
||||
<option value="rating">Rating</option>
|
||||
</select>
|
||||
|
||||
<button unicorn:click="set_sort('{{ sort_by }}')"
|
||||
class="px-2 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
{% if sort_order == 'asc' %}
|
||||
<i class="fas fa-sort-up"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-sort-down"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{% if is_loading %}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span class="text-lg font-medium text-gray-900 dark:text-white">Loading rides...</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Results grid -->
|
||||
{% if rides and not is_loading %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{% for ride in rides %}
|
||||
<div class="ride-card bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-lg transition-all duration-300">
|
||||
<!-- Ride image -->
|
||||
<div class="relative h-48 bg-gradient-to-br from-blue-500 to-purple-600">
|
||||
{% if ride.image %}
|
||||
<img src="{{ ride.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<i class="fas fa-rocket text-4xl text-white opacity-50"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Status badge -->
|
||||
<div class="absolute top-3 right-3">
|
||||
{% if ride.operating_status == 'operating' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<i class="fas fa-play-circle mr-1"></i>
|
||||
Operating
|
||||
</span>
|
||||
{% elif ride.operating_status == 'closed_temporarily' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
<i class="fas fa-pause-circle mr-1"></i>
|
||||
Temporarily Closed
|
||||
</span>
|
||||
{% elif ride.operating_status == 'closed_permanently' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
<i class="fas fa-stop-circle mr-1"></i>
|
||||
Permanently Closed
|
||||
</span>
|
||||
{% elif ride.operating_status == 'under_construction' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<i class="fas fa-hard-hat mr-1"></i>
|
||||
Under Construction
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride details -->
|
||||
<div class="p-5">
|
||||
<!-- Name and category -->
|
||||
<div class="mb-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
<a href="{% url 'rides:ride_detail' ride.id %}"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
</h3>
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<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-300 mr-2">
|
||||
{{ ride.category|default:"Ride" }}
|
||||
</span>
|
||||
{% if ride.park %}
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-map-marker-alt mr-1"></i>
|
||||
{{ ride.park.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key stats -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
{% if ride.height %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.height }}ft</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Height</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.max_speed %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.max_speed }}mph</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Max Speed</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.capacity_per_hour %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.capacity_per_hour }}</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Capacity/Hr</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.duration %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.duration }}s</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Duration</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Opening date -->
|
||||
{% if ride.opened_date %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
Opened {{ ride.opened_date|date:"F j, Y" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Manufacturer -->
|
||||
{% if ride.manufacturer %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-industry mr-1"></i>
|
||||
{{ ride.manufacturer.name }}
|
||||
{% if ride.designer and ride.designer != ride.manufacturer %}
|
||||
• Designed by {{ ride.designer.name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 and not is_loading %}
|
||||
<div class="mt-8 flex items-center justify-between">
|
||||
<div class="flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
<p>
|
||||
Showing {{ start_index }} to {{ end_index }} of {{ total_count }} results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if has_previous %}
|
||||
<button unicorn:click="previous_page"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-chevron-left mr-1"></i>
|
||||
Previous
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page numbers -->
|
||||
<div class="hidden sm:flex items-center space-x-1">
|
||||
{% for page_num in get_page_range %}
|
||||
{% if page_num == current_page %}
|
||||
<span class="inline-flex items-center px-3 py-2 border border-blue-500 bg-blue-50 text-blue-600 rounded-md text-sm font-medium dark:bg-blue-900 dark:text-blue-200 dark:border-blue-700">
|
||||
{{ page_num }}
|
||||
</span>
|
||||
{% else %}
|
||||
<button unicorn:click="go_to_page({{ page_num }})"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
{{ page_num }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_next %}
|
||||
<button unicorn:click="next_page"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
Next
|
||||
<i class="fas fa-chevron-right ml-1"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif not is_loading %}
|
||||
<!-- No results state -->
|
||||
<div class="text-center py-12">
|
||||
<div class="mx-auto h-24 w-24 text-gray-400 dark:text-gray-600 mb-4">
|
||||
<i class="fas fa-search text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No rides found</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{% if has_filters %}
|
||||
Try adjusting your filters to see more results.
|
||||
{% else %}
|
||||
No rides match your current search criteria.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if has_filters %}
|
||||
<button unicorn:click="clear_all"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear All Filters
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
1
backend/base64
Normal file
1
backend/base64
Normal file
@@ -0,0 +1 @@
|
||||
zsh: command not found: your-secret-key-for-jwt-tokens-change-this
|
||||
@@ -85,6 +85,7 @@ THIRD_PARTY_APPS = [
|
||||
"django_cleanup",
|
||||
"django_filters",
|
||||
"django_htmx",
|
||||
"django_unicorn", # Django Unicorn for reactive components
|
||||
"whitenoise",
|
||||
"django_tailwind_cli",
|
||||
"autocomplete", # Django HTMX Autocomplete
|
||||
|
||||
@@ -62,6 +62,7 @@ dependencies = [
|
||||
"djangorestframework-simplejwt>=5.5.1",
|
||||
"django-forwardemail>=1.0.0",
|
||||
"django-cloudflareimages-toolkit>=1.0.6",
|
||||
"django-unicorn>=0.62.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% load static %}
|
||||
{% load static unicorn %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -33,6 +33,9 @@
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="{% static 'js/alpine.min.js' %}"></script>
|
||||
|
||||
<!-- Django Unicorn Scripts -->
|
||||
{% unicorn_scripts %}
|
||||
|
||||
<!-- Location Autocomplete -->
|
||||
<script src="{% static 'js/location-autocomplete.js' %}"></script>
|
||||
|
||||
@@ -77,6 +80,7 @@
|
||||
<body
|
||||
class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="sticky top-0 z-40 border-b shadow-lg bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50"
|
||||
@@ -321,4 +325,4 @@
|
||||
<script src="{% static 'js/alerts.js' %}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,297 +1,8 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
{% load unicorn %}
|
||||
|
||||
{% block title %}ThrillWiki Moderation{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Base Styles */
|
||||
:root {
|
||||
--loading-gradient: linear-gradient(90deg, var(--tw-gradient-from) 0%, var(--tw-gradient-to) 50%, var(--tw-gradient-from) 100%);
|
||||
}
|
||||
|
||||
/* Responsive Layout */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-responsive {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@apply flex-col;
|
||||
}
|
||||
|
||||
.action-buttons > * {
|
||||
@apply w-full justify-center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-select {
|
||||
@apply rounded-lg border-gray-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 bg-gray-800 text-gray-300 transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* State Management */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.htmx-request .htmx-indicator {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.htmx-indicator {
|
||||
@apply opacity-0 transition-opacity duration-200;
|
||||
}
|
||||
|
||||
/* Skeleton Loading Animation */
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
/* Custom Animations */
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.animate-shimmer::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
background-image: var(--loading-gradient);
|
||||
animation: shimmer 2s infinite;
|
||||
content: '';
|
||||
}
|
||||
|
||||
/* Accessibility Enhancements */
|
||||
:focus-visible {
|
||||
@apply outline-hidden ring-2 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-gray-900;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-shimmer::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch Device Optimizations */
|
||||
@media (hover: none) {
|
||||
.hover\:shadow-md {
|
||||
@apply shadow-xs;
|
||||
}
|
||||
|
||||
.action-buttons > * {
|
||||
@apply active:transform active:scale-95;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Optimizations */
|
||||
.dark .animate-shimmer::after {
|
||||
--tw-gradient-from: rgba(31, 41, 55, 0);
|
||||
--tw-gradient-to: rgba(31, 41, 55, 0.1);
|
||||
}
|
||||
|
||||
/* Error States */
|
||||
.error-shake {
|
||||
animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
@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); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container max-w-6xl px-4 py-6 mx-auto">
|
||||
<div id="dashboard-content" class="relative transition-all duration-200">
|
||||
{% block moderation_content %}
|
||||
{% include "moderation/partials/dashboard_content.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div class="absolute inset-0 htmx-indicator" id="loading-skeleton">
|
||||
{% include "moderation/partials/loading_skeleton.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div class="absolute inset-0 hidden" id="error-state">
|
||||
<div class="flex flex-col items-center justify-center h-full p-6 space-y-4 text-center">
|
||||
<div class="p-4 text-red-500 bg-red-100 rounded-full dark:bg-red-900/40">
|
||||
<i class="text-4xl fas fa-exclamation-circle"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-red-600 dark:text-red-400">
|
||||
Something went wrong
|
||||
</h3>
|
||||
<p class="max-w-md text-gray-600 dark:text-gray-400" id="error-message">
|
||||
There was a problem loading the content. Please try again.
|
||||
</p>
|
||||
<button class="px-4 py-2 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600"
|
||||
onclick="window.location.reload()">
|
||||
<i class="mr-2 fas fa-sync-alt"></i>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// HTMX Configuration and Enhancements
|
||||
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
|
||||
});
|
||||
|
||||
// Loading and Error State Management
|
||||
const dashboard = {
|
||||
content: document.getElementById('dashboard-content'),
|
||||
skeleton: document.getElementById('loading-skeleton'),
|
||||
errorState: document.getElementById('error-state'),
|
||||
errorMessage: document.getElementById('error-message'),
|
||||
|
||||
showLoading() {
|
||||
this.content.setAttribute('aria-busy', 'true');
|
||||
this.content.style.opacity = '0';
|
||||
this.errorState.classList.add('hidden');
|
||||
},
|
||||
|
||||
hideLoading() {
|
||||
this.content.setAttribute('aria-busy', 'false');
|
||||
this.content.style.opacity = '1';
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.errorState.classList.remove('hidden');
|
||||
this.errorMessage.textContent = message || 'There was a problem loading the content. Please try again.';
|
||||
// Announce error to screen readers
|
||||
this.errorMessage.setAttribute('role', 'alert');
|
||||
}
|
||||
};
|
||||
|
||||
// Enhanced HTMX Event Handlers
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
dashboard.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
dashboard.hideLoading();
|
||||
// Reset focus for accessibility
|
||||
const firstFocusable = evt.detail.target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
dashboard.showError(evt.detail.error);
|
||||
}
|
||||
});
|
||||
|
||||
// Search Input Debouncing
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Apply debouncing to search inputs
|
||||
document.querySelectorAll('[data-search]').forEach(input => {
|
||||
const originalSearch = () => {
|
||||
htmx.trigger(input, 'input');
|
||||
};
|
||||
const debouncedSearch = debounce(originalSearch, 300);
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
e.preventDefault();
|
||||
debouncedSearch();
|
||||
});
|
||||
});
|
||||
|
||||
// Virtual Scrolling for Large Lists
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1
|
||||
};
|
||||
|
||||
const loadMoreContent = (entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !entry.target.classList.contains('loading')) {
|
||||
entry.target.classList.add('loading');
|
||||
htmx.trigger(entry.target, 'intersect');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(loadMoreContent, observerOptions);
|
||||
document.querySelectorAll('[data-infinite-scroll]').forEach(el => observer.observe(el));
|
||||
|
||||
// Keyboard Navigation Enhancement
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const openModals = document.querySelectorAll('[x-show="showNotes"]');
|
||||
openModals.forEach(modal => {
|
||||
const alpineData = modal.__x.$data;
|
||||
if (alpineData.showNotes) {
|
||||
alpineData.showNotes = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% unicorn 'moderation-dashboard' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,285 +1,8 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
{% load park_tags %}
|
||||
{% load unicorn %}
|
||||
|
||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if park.location.exists %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block title %}{{ park.name|default:"Park" }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoUploadModal', () => ({
|
||||
show: false,
|
||||
editingPhoto: { caption: '' }
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Action Buttons - Above header -->
|
||||
<div hx-get="{% url 'parks:park_actions' park.slug %}"
|
||||
hx-trigger="load, auth-changed from:body"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
<!-- Park Header -->
|
||||
<div class="p-compact mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white lg:text-4xl">{{ park.name }}</h1>
|
||||
{% if park.formatted_location %}
|
||||
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
<p>{{ park.formatted_location }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||
<span class="status-badge text-sm font-medium py-1 px-3 {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.average_rating %}
|
||||
<span class="flex items-center px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ park.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal Stats Bar -->
|
||||
<div class="grid-stats mb-6">
|
||||
<!-- Operator - Priority Card (First Position) -->
|
||||
{% if park.operator %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats card-stats-priority">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Operator</dt>
|
||||
<dd class="mt-1">
|
||||
<span class="text-sm font-bold text-sky-900 dark:text-sky-400">
|
||||
{{ park.operator.name }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Property Owner (if different from operator) -->
|
||||
{% if park.property_owner and park.property_owner != park.operator %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Property Owner</dt>
|
||||
<dd class="mt-1">
|
||||
<a href="{% url 'property_owners:property_owner_detail' park.property_owner.slug %}"
|
||||
class="text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300">
|
||||
{{ park.property_owner.name }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Total Rides -->
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}"
|
||||
class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats transition-transform hover:scale-[1.02]">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Total Rides</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300">{{ park.ride_count|default:"N/A" }}</dd>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Roller Coasters -->
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Roller Coasters</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400">{{ park.coaster_count|default:"N/A" }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Status</dt>
|
||||
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park.get_status_display }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opened Date -->
|
||||
{% if park.opening_date %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Opened</dt>
|
||||
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park.opening_date }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Website -->
|
||||
{% if park.website %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Website</dt>
|
||||
<dd class="mt-1">
|
||||
<a href="{{ park.website }}"
|
||||
class="inline-flex items-center text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
Visit
|
||||
<i class="ml-1 text-xs fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Rest of the content remains unchanged -->
|
||||
{% if park.photos.exists %}
|
||||
<div class="p-optimized mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
|
||||
{% include "media/partials/photo_display.html" with photos=park.photos.all content_type="parks.park" object_id=park.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Column - Description and Rides -->
|
||||
<div class="lg:col-span-2">
|
||||
{% if park.description %}
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{{ park.description|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rides and Attractions -->
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
{% if park.rides.exists %}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{% for ride in park.rides.all|slice:":6" %}
|
||||
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<a href="{% url 'parks:rides:ride_detail' park.slug ride.slug %}" class="block">
|
||||
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">{{ ride.name }}</h3>
|
||||
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
{% if ride.average_rating %}
|
||||
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ ride.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides or attractions listed yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Map and Additional Info -->
|
||||
<div class="lg:col-span-1">
|
||||
<!-- Location Map -->
|
||||
{% if park.location.exists %}
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Location</h2>
|
||||
<div id="park-map" class="relative rounded-lg" style="z-index: 0;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- History Panel -->
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
|
||||
<div class="space-y-4">
|
||||
{% for record in history %}
|
||||
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ record.history_date|date:"M d, Y H:i" }}
|
||||
{% if record.history_user %}
|
||||
by {{ record.history_user.username }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{% for field, changes in record.diff_against_previous.items %}
|
||||
{% if field != "updated_at" %}
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{{ field|title }}:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{{ changes.old }}</span>
|
||||
<span class="mx-1">→</span>
|
||||
<span class="text-green-600 dark:text-green-400">{{ changes.new }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-gray-500">No history available.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Upload Modal -->
|
||||
{% if perms.media.add_photo %}
|
||||
<div x-cloak
|
||||
x-data="{
|
||||
show: false,
|
||||
editingPhoto: null,
|
||||
init() {
|
||||
this.editingPhoto = { caption: '' };
|
||||
}
|
||||
}"
|
||||
@show-photo-upload.window="show = true; init()"
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50"
|
||||
@click.self="show = false">
|
||||
<div class="w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Upload Photos</h3>
|
||||
<button @click="show = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<i class="text-xl fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% include "media/partials/photo_upload.html" with content_type="parks.park" object_id=park.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Photo Gallery Script -->
|
||||
<script src="{% static 'js/photo-gallery.js' %}"></script>
|
||||
|
||||
<!-- Map Script (if location exists) -->
|
||||
{% if park.location.exists %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="{% static 'js/park-map.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% with location=park.location.first %}
|
||||
initParkMap({{ location.latitude }}, {{ location.longitude }}, "{{ park.name }}");
|
||||
{% endwith %}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% unicorn 'park-detail' park_slug=park.slug %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,93 +1,11 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
{% load static unicorn %}
|
||||
|
||||
{% block title %}Parks{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Consolidated Search and View Controls Bar -->
|
||||
<div class="bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<!-- Search Section -->
|
||||
<div class="flex-1 max-w-2xl">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" 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>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value="{{ search_query }}"
|
||||
placeholder="Search parks by name, location, or features..."
|
||||
class="block w-full pl-10 pr-3 py-2 border border-gray-600 rounded-md leading-5 bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='view_mode']"
|
||||
hx-indicator="#search-spinner"
|
||||
/>
|
||||
<div id="search-spinner" class="absolute inset-y-0 right-0 pr-3 flex items-center htmx-indicator">
|
||||
<svg class="animate-spin h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Count and View Controls -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Results Count -->
|
||||
<div class="text-gray-300 text-sm whitespace-nowrap">
|
||||
<span class="font-medium">Parks</span>
|
||||
{% if total_results %}
|
||||
<span class="text-gray-400">({{ total_results }} found)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="flex bg-gray-700 rounded-lg p-1">
|
||||
<input type="hidden" name="view_mode" value="{{ view_mode }}" />
|
||||
|
||||
<!-- Grid View Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'grid' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
|
||||
title="Grid View"
|
||||
hx-get="{% url 'parks:park_list' %}?view_mode=grid"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='search']"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- List View Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'list' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
|
||||
title="List View"
|
||||
hx-get="{% url 'parks:park_list' %}?view_mode=list"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='search']"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="park-results">
|
||||
{% include "parks/partials/park_list.html" %}
|
||||
</div>
|
||||
<!-- Django Unicorn Park Search Component -->
|
||||
{% unicorn 'park-search' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
218
backend/templates/rides/partials/ride_filter_sidebar.html
Normal file
218
backend/templates/rides/partials/ride_filter_sidebar.html
Normal file
@@ -0,0 +1,218 @@
|
||||
<!-- Simplified Ride Filter Sidebar for Django Unicorn -->
|
||||
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto w-80">
|
||||
<!-- 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>
|
||||
Filters
|
||||
</h2>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Filter Count Badge -->
|
||||
{% if get_active_filter_count > 0 %}
|
||||
<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">
|
||||
{{ get_active_filter_count }} active
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Clear All Filters -->
|
||||
{% if has_filters %}
|
||||
<button unicorn:click="clear_all"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Filter Content -->
|
||||
<div class="p-4 space-y-6">
|
||||
<!-- Basic Category Filter -->
|
||||
<div class="filter-section">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-tags mr-2 text-gray-500"></i>
|
||||
Category
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Roller Coaster</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Water Ride</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Flat Ride</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Dark Ride</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="filter-section border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-power-off mr-2 text-gray-500"></i>
|
||||
Status
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-green-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Operating</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-yellow-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Temporarily Closed</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-red-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Permanently Closed</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Under Construction</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Height Range Filter -->
|
||||
<div class="filter-section border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-ruler-vertical mr-2 text-gray-500"></i>
|
||||
Height Range (ft)
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Min</label>
|
||||
<input type="number"
|
||||
placeholder="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Max</label>
|
||||
<input type="number"
|
||||
placeholder="500"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speed Range Filter -->
|
||||
<div class="filter-section border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-tachometer-alt mr-2 text-gray-500"></i>
|
||||
Speed Range (mph)
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Min</label>
|
||||
<input type="number"
|
||||
placeholder="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Max</label>
|
||||
<input type="number"
|
||||
placeholder="150"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opening Date Range -->
|
||||
<div class="filter-section border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-calendar mr-2 text-gray-500"></i>
|
||||
Opening Date Range
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">From</label>
|
||||
<input type="date"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">To</label>
|
||||
<input type="date"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manufacturer Filter -->
|
||||
<div class="filter-section border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-industry mr-2 text-gray-500"></i>
|
||||
Manufacturer
|
||||
</h3>
|
||||
<div class="space-y-2 max-h-32 overflow-y-auto">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Bolliger & Mabillard</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Intamin</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Vekoma</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Arrow Dynamics</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-blue-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Gerstlauer</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roller Coaster Features -->
|
||||
<div class="filter-section border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-mountain mr-2 text-gray-500"></i>
|
||||
Features
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-purple-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Inversions</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-red-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Launch</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-green-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Steel Track</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="form-checkbox text-yellow-600">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Wood Track</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply Filters Button -->
|
||||
<div class="filter-section border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<button class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors">
|
||||
<i class="fas fa-search mr-2"></i>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSS for form elements -->
|
||||
<style>
|
||||
.form-checkbox {
|
||||
@apply w-4 h-4 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load unicorn %}
|
||||
|
||||
{% block title %}
|
||||
{% if park %}
|
||||
@@ -9,250 +9,7 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
/* Custom styles for the ride filtering interface */
|
||||
.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 {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.filter-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.filter-content.open {
|
||||
max-height: 1000px;
|
||||
}
|
||||
|
||||
/* Custom form styling */
|
||||
.form-input, .form-select, .form-textarea {
|
||||
@apply 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;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
@apply 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;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1;
|
||||
}
|
||||
|
||||
/* Ride card animations */
|
||||
.ride-card {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.ride-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile filter overlay */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-filter-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.mobile-filter-panel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: white;
|
||||
z-index: 50;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.mobile-filter-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Mobile filter overlay -->
|
||||
<div id="mobile-filter-overlay" class="mobile-filter-overlay hidden lg:hidden"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-full px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Title and breadcrumb -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{% if park %}
|
||||
<i class="fas fa-map-marker-alt mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
{{ park.name }} - Rides
|
||||
{% else %}
|
||||
<i class="fas fa-rocket mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
All Rides
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<!-- Mobile filter toggle -->
|
||||
<button id="mobile-filter-toggle"
|
||||
class="lg:hidden inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Filters
|
||||
{% if has_filters %}
|
||||
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ active_filters|length }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results summary -->
|
||||
<div class="hidden sm:flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
<i class="fas fa-list mr-1"></i>
|
||||
{{ filtered_count }} of {{ total_rides }} rides
|
||||
</span>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div class="htmx-indicator">
|
||||
<i class="fas fa-spinner fa-spin text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex">
|
||||
<!-- Desktop filter sidebar -->
|
||||
<div class="hidden lg:block">
|
||||
{% include "rides/partials/filter_sidebar.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile filter panel -->
|
||||
<div id="mobile-filter-panel" class="mobile-filter-panel lg:hidden dark:bg-gray-900">
|
||||
<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">Filters</h3>
|
||||
<button id="mobile-filter-close" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% include "rides/partials/filter_sidebar.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Results area -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div id="filter-results" class="p-4 lg:p-6">
|
||||
{% include "rides/partials/ride_list_results.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile filter JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const mobileToggle = document.getElementById('mobile-filter-toggle');
|
||||
const mobilePanel = document.getElementById('mobile-filter-panel');
|
||||
const mobileOverlay = document.getElementById('mobile-filter-overlay');
|
||||
const mobileClose = document.getElementById('mobile-filter-close');
|
||||
|
||||
function openMobileFilter() {
|
||||
mobilePanel.classList.add('open');
|
||||
mobileOverlay.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeMobileFilter() {
|
||||
mobilePanel.classList.remove('open');
|
||||
mobileOverlay.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
if (mobileToggle) {
|
||||
mobileToggle.addEventListener('click', openMobileFilter);
|
||||
}
|
||||
|
||||
if (mobileClose) {
|
||||
mobileClose.addEventListener('click', closeMobileFilter);
|
||||
}
|
||||
|
||||
if (mobileOverlay) {
|
||||
mobileOverlay.addEventListener('click', closeMobileFilter);
|
||||
}
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && mobilePanel.classList.contains('open')) {
|
||||
closeMobileFilter();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dark mode toggle (if not already implemented globally)
|
||||
function toggleDarkMode() {
|
||||
document.documentElement.classList.toggle('dark');
|
||||
localStorage.setItem('darkMode', document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
|
||||
// Initialize dark mode from localStorage
|
||||
if (localStorage.getItem('darkMode') === 'true' ||
|
||||
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
<!-- Django Unicorn Ride List Component -->
|
||||
{% unicorn 'ride-list' park_slug=park.slug %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,169 +1,10 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
{% load unicorn %}
|
||||
|
||||
{% block title %}Search Results - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 py-8 mx-auto">
|
||||
<!-- Search Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="mb-2 text-3xl font-bold">Search Results</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{% if request.GET.q %}
|
||||
Results for "{{ request.GET.q }}"
|
||||
{% else %}
|
||||
Enter a search term above to find parks, rides, and more
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if request.GET.q %}
|
||||
<!-- Parks Results -->
|
||||
<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</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">
|
||||
{% 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">
|
||||
<span class="text-gray-400">No image available</span>
|
||||
</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">{{ park.location }}</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.rides.count }} attractions
|
||||
</a>
|
||||
{% if park.average_rating %}
|
||||
<div class="flex items-center">
|
||||
<span class="mr-1 text-yellow-400">★</span>
|
||||
<span>{{ park.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="py-4 text-center col-span-full">
|
||||
<p class="text-gray-500 dark:text-gray-400">No parks found matching your search.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rides Results -->
|
||||
<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</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">
|
||||
{% 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">
|
||||
<span class="text-gray-400">No image available</span>
|
||||
</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">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
{% if ride.average_rating %}
|
||||
<div class="flex items-center">
|
||||
<span class="mr-1 text-yellow-400">★</span>
|
||||
<span>{{ ride.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="py-4 text-center col-span-full">
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides found matching your search.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operators Results -->
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Park Operators</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 }}
|
||||
</a>
|
||||
</h3>
|
||||
{% if operator.headquarters %}
|
||||
<p class="mb-2 text-gray-600 dark:text-gray-400">{{ operator.headquarters }}</p>
|
||||
{% endif %}
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ operator.operated_parks.count }} parks operated
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="py-4 text-center col-span-full">
|
||||
<p class="text-gray-500 dark:text-gray-400">No operators found matching your search.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Property Owners Results -->
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Property Owners</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">
|
||||
<a href="{% url 'property_owners:property_owner_detail' property_owner.slug %}"
|
||||
class="text-blue-600 hover:underline dark:text-blue-400">
|
||||
{{ property_owner.name }}
|
||||
</a>
|
||||
</h3>
|
||||
{% if property_owner.headquarters %}
|
||||
<p class="mb-2 text-gray-600 dark:text-gray-400">{{ property_owner.headquarters }}</p>
|
||||
{% endif %}
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ property_owner.owned_parks.count }} properties owned
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="py-4 text-center col-span-full">
|
||||
<p class="text-gray-500 dark:text-gray-400">No property owners found matching your search.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% unicorn 'search-results' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -45,8 +45,12 @@ urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
# Main app URLs
|
||||
path("", HomeView.as_view(), name="home"),
|
||||
# Global search URL - fixes missing route for SearchView
|
||||
path("search/", views.SearchView.as_view(), name="global_search"),
|
||||
# Health Check URLs
|
||||
path("health/", include("health_check.urls")),
|
||||
# Django Unicorn URLs
|
||||
path("unicorn/", include("django_unicorn.urls")),
|
||||
# Centralized API URLs - routes through main API router
|
||||
path("api/", include("apps.api.urls")),
|
||||
# All API endpoints are now consolidated under /api/v1/
|
||||
|
||||
102
backend/uv.lock
generated
102
backend/uv.lock
generated
@@ -93,6 +93,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/2e/3e5079847e653b1f6dc647aa24549d68c6addb4c595cc0d902d1b19308ad/beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695", size = 622954, upload-time = "2025-08-24T14:06:13.168Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/eb/f4151e0c7377a6e08a38108609ba5cede57986802757848688aeedd1b9e8/beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a", size = 105113, upload-time = "2025-08-24T14:06:14.884Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.1"
|
||||
@@ -163,6 +176,15 @@ filecache = [
|
||||
{ name = "filelock" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "6.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "celery"
|
||||
version = "5.5.3"
|
||||
@@ -475,6 +497,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/34/6171ab34715ed210bcd6c2b38839cc792993cff4fe2493f50bc92b0086a0/daphne-4.2.1-py3-none-any.whl", hash = "sha256:881e96b387b95b35ad85acd855f229d7f5b79073d6649089c8a33f661885e055", size = 29015, upload-time = "2025-07-02T12:57:03.793Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decorator"
|
||||
version = "5.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
@@ -842,6 +873,23 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/b6/6cdf17830ba65836e370158028163b0e826d5c744df8288db5e4b20b6afc/django_typer-3.2.2-py3-none-any.whl", hash = "sha256:a97d0e5e5582d4648f372c688c2aec132540caa04e2578329b13a55588477e11", size = 295649, upload-time = "2025-07-17T22:57:14.996Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-unicorn"
|
||||
version = "0.62.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "cachetools" },
|
||||
{ name = "decorator" },
|
||||
{ name = "django" },
|
||||
{ name = "orjson" },
|
||||
{ name = "shortuuid" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/4b/767fde742ce6d17f6a55886bcbcb19ba6dfe5698e6c80f304f7887c58a36/django_unicorn-0.62.0.tar.gz", hash = "sha256:dedf97e4c59e288537911c995a6d0c08615bd3274bb77b43e8031f4c2d365082", size = 85283, upload-time = "2025-02-03T04:00:30.833Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/db/490c1ae999278101a99f90e21a7863fa3679092784209e95dcdd62fb8061/django_unicorn-0.62.0-py3-none-any.whl", hash = "sha256:f728b0ad030334cf2602f2d076e464984f683fb031a22afa794a0d2fe1468ecf", size = 96385, upload-time = "2025-02-03T04:00:28.8Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-webpack-loader"
|
||||
version = "3.2.1"
|
||||
@@ -1337,6 +1385,40 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/4d/8df5f83256a809c22c4d6792ce8d43bb503be0fb7a8e4da9025754b09658/orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a", size = 5482394, upload-time = "2025-08-26T17:46:43.171Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/79/8932b27293ad35919571f77cb3693b5906cf14f206ef17546052a241fdf6/orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810", size = 238127, upload-time = "2025-08-26T17:45:38.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/82/cb93cd8cf132cd7643b30b6c5a56a26c4e780c7a145db6f83de977b540ce/orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43", size = 127494, upload-time = "2025-08-26T17:45:39.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/b8/2d9eb181a9b6bb71463a78882bcac1027fd29cf62c38a40cc02fc11d3495/orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27", size = 123017, upload-time = "2025-08-26T17:45:40.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/14/a0e971e72d03b509190232356d54c0f34507a05050bd026b8db2bf2c192c/orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f", size = 127898, upload-time = "2025-08-26T17:45:42.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/af/dc74536722b03d65e17042cc30ae586161093e5b1f29bccda24765a6ae47/orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c", size = 130742, upload-time = "2025-08-26T17:45:43.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e6/7a3b63b6677bce089fe939353cda24a7679825c43a24e49f757805fc0d8a/orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be", size = 132377, upload-time = "2025-08-26T17:45:45.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/cd/ce2ab93e2e7eaf518f0fd15e3068b8c43216c8a44ed82ac2b79ce5cef72d/orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d", size = 135313, upload-time = "2025-08-26T17:45:46.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2", size = 132908, upload-time = "2025-08-26T17:45:48.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/92/8f5182d7bc2a1bed46ed960b61a39af8389f0ad476120cd99e67182bfb6d/orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f", size = 130905, upload-time = "2025-08-26T17:45:49.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/60/c41ca753ce9ffe3d0f67b9b4c093bdd6e5fdb1bc53064f992f66bb99954d/orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee", size = 403812, upload-time = "2025-08-26T17:45:51.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/13/e4a4f16d71ce1868860db59092e78782c67082a8f1dc06a3788aef2b41bc/orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e", size = 146277, upload-time = "2025-08-26T17:45:52.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/8b/bafb7f0afef9344754a3a0597a12442f1b85a048b82108ef2c956f53babd/orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633", size = 135418, upload-time = "2025-08-26T17:45:54.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d4/bae8e4f26afb2c23bea69d2f6d566132584d1c3a5fe89ee8c17b718cab67/orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b", size = 136216, upload-time = "2025-08-26T17:45:57.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/76/224985d9f127e121c8cad882cea55f0ebe39f97925de040b75ccd4b33999/orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae", size = 131362, upload-time = "2025-08-26T17:45:58.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/cf/0dce7a0be94bd36d1346be5067ed65ded6adb795fdbe3abd234c8d576d01/orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce", size = 125989, upload-time = "2025-08-26T17:45:59.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/77/d3b1fef1fc6aaeed4cbf3be2b480114035f4df8fa1a99d2dac1d40d6e924/orjson-3.11.3-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cf4b81227ec86935568c7edd78352a92e97af8da7bd70bdfdaa0d2e0011a1ab4", size = 238115, upload-time = "2025-08-26T17:46:01.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/6d/468d21d49bb12f900052edcfbf52c292022d0a323d7828dc6376e6319703/orjson-3.11.3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:bc8bc85b81b6ac9fc4dae393a8c159b817f4c2c9dee5d12b773bddb3b95fc07e", size = 127493, upload-time = "2025-08-26T17:46:03.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/46/1e2588700d354aacdf9e12cc2d98131fb8ac6f31ca65997bef3863edb8ff/orjson-3.11.3-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:88dcfc514cfd1b0de038443c7b3e6a9797ffb1b3674ef1fd14f701a13397f82d", size = 122998, upload-time = "2025-08-26T17:46:04.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/94/11137c9b6adb3779f1b34fd98be51608a14b430dbc02c6d41134fbba484c/orjson-3.11.3-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d61cd543d69715d5fc0a690c7c6f8dcc307bc23abef9738957981885f5f38229", size = 132915, upload-time = "2025-08-26T17:46:06.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/61/dccedcf9e9bcaac09fdabe9eaee0311ca92115699500efbd31950d878833/orjson-3.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2b7b153ed90ababadbef5c3eb39549f9476890d339cf47af563aea7e07db2451", size = 130907, upload-time = "2025-08-26T17:46:07.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/fd/0e935539aa7b08b3ca0f817d73034f7eb506792aae5ecc3b7c6e679cdf5f/orjson-3.11.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7909ae2460f5f494fecbcd10613beafe40381fd0316e35d6acb5f3a05bfda167", size = 403852, upload-time = "2025-08-26T17:46:08.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/2b/50ae1a5505cd1043379132fdb2adb8a05f37b3e1ebffe94a5073321966fd/orjson-3.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2030c01cbf77bc67bee7eef1e7e31ecf28649353987775e3583062c752da0077", size = 146309, upload-time = "2025-08-26T17:46:10.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/1d/a473c158e380ef6f32753b5f39a69028b25ec5be331c2049a2201bde2e19/orjson-3.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0169ebd1cbd94b26c7a7ad282cf5c2744fce054133f959e02eb5265deae1872", size = 135424, upload-time = "2025-08-26T17:46:12.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/09/17d9d2b60592890ff7382e591aa1d9afb202a266b180c3d4049b1ec70e4a/orjson-3.11.3-cp314-cp314-win32.whl", hash = "sha256:0c6d7328c200c349e3a4c6d8c83e0a5ad029bdc2d417f234152bf34842d0fc8d", size = 136266, upload-time = "2025-08-26T17:46:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/58/358f6846410a6b4958b74734727e582ed971e13d335d6c7ce3e47730493e/orjson-3.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:317bbe2c069bbc757b1a2e4105b64aacd3bc78279b66a6b9e51e846e4809f804", size = 131351, upload-time = "2025-08-26T17:46:15.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/01/d6b274a0635be0468d4dbd9cafe80c47105937a0d42434e805e67cd2ed8b/orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc", size = 125985, upload-time = "2025-08-26T17:46:16.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
@@ -2127,6 +2209,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shortuuid"
|
||||
version = "1.0.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72", size = 9662, upload-time = "2024-03-11T20:11:06.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a", size = 10529, upload-time = "2024-03-11T20:11:04.807Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -2145,6 +2236,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.3"
|
||||
@@ -2197,6 +2297,7 @@ dependencies = [
|
||||
{ name = "django-silk" },
|
||||
{ name = "django-simple-history" },
|
||||
{ name = "django-tailwind-cli" },
|
||||
{ name = "django-unicorn" },
|
||||
{ name = "django-webpack-loader" },
|
||||
{ name = "django-widget-tweaks" },
|
||||
{ name = "djangorestframework" },
|
||||
@@ -2268,6 +2369,7 @@ requires-dist = [
|
||||
{ name = "django-silk", specifier = ">=5.0.0" },
|
||||
{ name = "django-simple-history", specifier = ">=3.5.0" },
|
||||
{ name = "django-tailwind-cli", specifier = ">=2.21.1" },
|
||||
{ name = "django-unicorn", specifier = ">=0.62.0" },
|
||||
{ name = "django-webpack-loader", specifier = ">=3.1.1" },
|
||||
{ name = "django-widget-tweaks", specifier = ">=1.5.0" },
|
||||
{ name = "djangorestframework", specifier = ">=3.14.0" },
|
||||
|
||||
Reference in New Issue
Block a user