Compare commits

..

19 Commits

Author SHA1 Message Date
pacnpal
679de16e4f Refactor account adapters and admin classes; enhance type hinting for better clarity and maintainability, ensuring consistent typing across methods and improving overall code quality. 2025-09-27 11:59:29 -04:00
pacnpal
31a2d84f9f Refactor type checking configuration; consolidate settings from pyrightconfig.json into pyproject.toml for improved project structure and clarity. 2025-09-27 11:48:31 -04:00
pacnpal
7d04c2baa0 Refactor environment variable configurations for consistency; ensure proper type casting for DEBUG and ALLOWED_HOSTS settings. 2025-09-27 11:42:25 -04:00
pacnpal
6575ea68c7 Add advanced search and trending parks features; update frontend dependencies and enhance home page layout 2025-09-27 09:42:12 -04:00
pacnpal
e1cb76f1c6 Refactor ParkLocation model to inherit from TrackedModel for enhanced history tracking. Update point handling to temporarily store coordinates as a string. Implement Haversine formula for distance calculation as a placeholder until PostGIS is enabled.
Refactor advanced search template to utilize Alpine.js for state management. Enhance search functionality with dynamic view modes and improved filter handling using HTMX.
2025-09-26 15:56:28 -04:00
pacnpal
acc8308fd2 Refactor location widget to utilize Alpine.js for state management and HTMX for AJAX interactions. Removed legacy JavaScript functions and streamlined event handling for improved user experience. 2025-09-26 15:42:07 -04:00
pacnpal
de8b6f67a3 Refactor ride filters and forms to use AlpineJS for state management and HTMX for AJAX interactions
- Enhanced filter sidebar with AlpineJS for collapsible sections and localStorage persistence.
- Removed custom JavaScript in favor of AlpineJS for managing filter states and interactions.
- Updated ride form to utilize AlpineJS for handling manufacturer, designer, and ride model selections.
- Simplified search script to leverage AlpineJS for managing search input and suggestions.
- Improved error handling for HTMX requests with minimal JavaScript.
- Refactored ride form data handling to encapsulate logic within an AlpineJS component.
2025-09-26 15:25:12 -04:00
pacnpal
c437ddbf28 Enhance moderation dashboard with Alpine.js for improved state management and event handling. Added x-data and event listener for retry functionality. 2025-09-26 14:53:17 -04:00
pacnpal
f7b1296263 Refactor moderation dashboard and advanced search components to utilize Alpine.js for improved state management. Enhanced event handling and user experience by replacing legacy JavaScript functions with Alpine.js reactive methods. Updated auth modal comparison and button comparison tests to leverage Alpine.js for better interactivity and functionality. 2025-09-26 14:48:13 -04:00
pacnpal
e53414d795 Refactor park search results and search suggestions to utilize Alpine.js for improved state management. Enhanced event handling and user experience by replacing legacy JavaScript functions with Alpine.js reactive methods. 2025-09-26 14:39:15 -04:00
pacnpal
2328c919c9 Refactor ride model form to remove legacy JavaScript functions. Streamlined event handling by leveraging Alpine.js for improved state management and user experience. 2025-09-26 14:35:39 -04:00
pacnpal
09e2c69493 Refactor designer, manufacturer, and ride model forms to utilize Alpine.js for state management. Improved form submission handling, HTMX event integration, and enhanced user experience through better event dispatching and modal management. 2025-09-26 14:34:59 -04:00
pacnpal
5b7b203619 Refactor add ride modal to utilize Alpine.js for state management. Improved modal open/close functionality and enhanced event handling for better user experience. 2025-09-26 14:32:10 -04:00
pacnpal
47c435d2f5 Refactor ride model search results template to utilize Alpine.js for state management. Enhanced selection handling and improved event dispatching for better user experience. 2025-09-26 14:31:15 -04:00
pacnpal
ce382a4361 Refactor designer search results template to utilize Alpine.js for state management. Enhanced designer selection handling and improved event dispatching for better user experience. 2025-09-26 14:30:22 -04:00
pacnpal
07ab9f28f2 Refactor manufacturer search results template to utilize Alpine.js for state management. Enhanced manufacturer selection handling and improved event dispatching for better user experience. 2025-09-26 14:29:37 -04:00
pacnpal
40e5cf3162 Refactor ride form template to utilize Alpine.js for state management. Enhanced form submission handling and improved search result clearing functionality for better user experience. 2025-09-26 14:27:47 -04:00
pacnpal
b9377ead37 Refactor designer and ride model search results templates to utilize Alpine.js for state management. Enhanced selection functionality and improved event handling for better user experience. 2025-09-26 14:23:03 -04:00
pacnpal
851709058f Refactor location widget and park search results templates to utilize Alpine.js for state management. Enhanced search functionality, improved data binding, and streamlined event handling for better user experience. 2025-09-26 14:21:28 -04:00
42 changed files with 2326 additions and 2512 deletions

View File

@@ -12,8 +12,8 @@ tags: ["django", "architecture", "context7-integration", "thrillwiki"]
Theme park database platform with Django REST Framework serving 120+ API endpoints for parks, rides, companies, and users.
## Core Architecture
- **Backend**: Django 5.0+, DRF, PostgreSQL+PostGIS, Redis, Celery
- **Frontend**: HTMX + AlpineJS + Tailwind CSS + Django-Cotton
- **Backend**: Django 5.1+, DRF, PostgreSQL+PostGIS, Redis, Celery
- **Frontend**: HTMX (V2+) + AlpineJS + Tailwind CSS (V4+) + Django-Cotton
- 🚨 **ABSOLUTELY NO Custom JS** - use HTMX + AlpineJS ONLY
- Clean, simple UX preferred
- **Media**: Cloudflare Images with Direct Upload
@@ -49,4 +49,8 @@ tailwindcss, django, django-cotton, htmx, alpinejs, django-rest-framework, postg
- All models inherit TrackedModel
- Real database data only (NO MOCKING)
- RichChoiceField over Django choices
- Progressive enhancement required
- Progressive enhancement required
- We prefer to edit existing files instead of creating new ones.
YOU ARE STRICTLY AND ABSOLUTELY FORBIDDEN FROM IGNORING, BYPASSING, OR AVOIDING THESE RULES IN ANY WAY WITH NO EXCEPTIONS!!!

View File

@@ -1,64 +1,95 @@
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.http import HttpRequest
from typing import Optional, Any, Dict, Literal, TYPE_CHECKING, cast
from allauth.account.adapter import DefaultAccountAdapter # type: ignore[import]
from allauth.account.models import EmailConfirmation, EmailAddress # type: ignore[import]
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter # type: ignore[import]
from allauth.socialaccount.models import SocialLogin # type: ignore[import]
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
User = get_user_model()
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
def is_open_for_signup(self, request: HttpRequest) -> Literal[True]:
"""
Whether to allow sign ups.
"""
return True
def get_email_confirmation_url(self, request, emailconfirmation):
def get_email_confirmation_url(self, request: HttpRequest, emailconfirmation: EmailConfirmation) -> str:
"""
Constructs the email confirmation (activation) url.
"""
get_current_site(request)
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
# Ensure the key is treated as a string for the type checker
key = cast(str, getattr(emailconfirmation, "key", ""))
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={key}"
def send_confirmation_mail(self, request, emailconfirmation, signup):
def send_confirmation_mail(self, request: HttpRequest, emailconfirmation: EmailConfirmation, signup: bool) -> None:
"""
Sends the confirmation email.
"""
current_site = get_current_site(request)
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
ctx = {
"user": emailconfirmation.email_address.user,
"activate_url": activate_url,
"current_site": current_site,
"key": emailconfirmation.key,
}
# Cast key to str for typing consistency and template context
key = cast(str, getattr(emailconfirmation, "key", ""))
# Determine template early
if signup:
email_template = "account/email/email_confirmation_signup"
else:
email_template = "account/email/email_confirmation"
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
# Cast the possibly-unknown email_address to EmailAddress so the type checker knows its attributes
email_address = cast(EmailAddress, getattr(emailconfirmation, "email_address", None))
# Safely obtain email string (fallback to any top-level email on confirmation)
email_str = cast(str, getattr(email_address, "email", getattr(emailconfirmation, "email", "")))
# Safely obtain the user object, cast to the project's User model for typing
user_obj = cast("AbstractUser", getattr(email_address, "user", None))
# Explicitly type the context to avoid partial-unknown typing issues
ctx: Dict[str, Any] = {
"user": user_obj,
"activate_url": activate_url,
"current_site": current_site,
"key": key,
}
# Remove unnecessary cast; ctx is already Dict[str, Any]
self.send_mail(email_template, email_str, ctx) # type: ignore
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> Literal[True]:
"""
Whether to allow social account sign ups.
"""
return True
def populate_user(self, request, sociallogin, data):
def populate_user(
self, request: HttpRequest, sociallogin: SocialLogin, data: Dict[str, Any]
) -> "AbstractUser": # type: ignore[override]
"""
Hook that can be used to further populate the user instance.
"""
user = super().populate_user(request, sociallogin, data)
if sociallogin.account.provider == "discord":
user.discord_id = sociallogin.account.uid
return user
user = super().populate_user(request, sociallogin, data) # type: ignore
if getattr(sociallogin.account, "provider", None) == "discord": # type: ignore
user.discord_id = getattr(sociallogin.account, "uid", None) # type: ignore
return cast("AbstractUser", user) # Ensure return type is explicit
def save_user(self, request, sociallogin, form=None):
def save_user(
self, request: HttpRequest, sociallogin: SocialLogin, form: Optional[Any] = None
) -> "AbstractUser": # type: ignore[override]
"""
Save the newly signed up social login.
"""
user = super().save_user(request, sociallogin, form)
return user
user = super().save_user(request, sociallogin, form) # type: ignore
if user is None:
raise ValueError("User creation failed")
return cast("AbstractUser", user) # Ensure return type is explicit

View File

@@ -1,7 +1,10 @@
from typing import Any
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.utils.html import format_html
from django.contrib.auth.models import Group
from django.http import HttpRequest
from django.db.models import QuerySet
from .models import (
User,
UserProfile,
@@ -12,7 +15,7 @@ from .models import (
)
class UserProfileInline(admin.StackedInline):
class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite]):
model = UserProfile
can_delete = False
verbose_name_plural = "Profile"
@@ -39,7 +42,7 @@ class UserProfileInline(admin.StackedInline):
)
class TopListItemInline(admin.TabularInline):
class TopListItemInline(admin.TabularInline[TopListItem]):
model = TopListItem
extra = 1
fields = ("content_type", "object_id", "rank", "notes")
@@ -47,7 +50,7 @@ class TopListItemInline(admin.TabularInline):
@admin.register(User)
class CustomUserAdmin(UserAdmin):
class CustomUserAdmin(DjangoUserAdmin[User]):
list_display = (
"username",
"email",
@@ -74,7 +77,7 @@ class CustomUserAdmin(UserAdmin):
"ban_users",
"unban_users",
]
inlines = [UserProfileInline]
inlines: list[type[admin.StackedInline[UserProfile]]] = [UserProfileInline]
fieldsets = (
(None, {"fields": ("username", "password")}),
@@ -126,75 +129,82 @@ class CustomUserAdmin(UserAdmin):
)
@admin.display(description="Avatar")
def get_avatar(self, obj):
if obj.profile.avatar:
def get_avatar(self, obj: User) -> str:
profile = getattr(obj, "profile", None)
if profile and getattr(profile, "avatar", None):
return format_html(
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
obj.profile.avatar.url,
'<img src="{0}" width="30" height="30" style="border-radius:50%;" />',
getattr(profile.avatar, "url", ""), # type: ignore
)
return format_html(
'<div style="width:30px; height:30px; border-radius:50%; '
"background-color:#007bff; color:white; display:flex; "
'align-items:center; justify-content:center;">{}</div>',
obj.username[0].upper(),
'align-items:center; justify-content:center;">{0}</div>',
getattr(obj, "username", "?")[0].upper(), # type: ignore
)
@admin.display(description="Status")
def get_status(self, obj):
if obj.is_banned:
return format_html('<span style="color: red;">Banned</span>')
if not obj.is_active:
return format_html('<span style="color: orange;">Inactive</span>')
if obj.is_superuser:
return format_html('<span style="color: purple;">Superuser</span>')
if obj.is_staff:
return format_html('<span style="color: blue;">Staff</span>')
return format_html('<span style="color: green;">Active</span>')
def get_status(self, obj: User) -> str:
if getattr(obj, "is_banned", False):
return format_html('<span style="color: red;">{}</span>', "Banned")
if not getattr(obj, "is_active", True):
return format_html('<span style="color: orange;">{}</span>', "Inactive")
if getattr(obj, "is_superuser", False):
return format_html('<span style="color: purple;">{}</span>', "Superuser")
if getattr(obj, "is_staff", False):
return format_html('<span style="color: blue;">{}</span>', "Staff")
return format_html('<span style="color: green;">{}</span>', "Active")
@admin.display(description="Ride Credits")
def get_credits(self, obj):
def get_credits(self, obj: User) -> str:
try:
profile = obj.profile
profile = getattr(obj, "profile", None)
if not profile:
return "-"
return format_html(
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
profile.coaster_credits,
profile.dark_ride_credits,
profile.flat_ride_credits,
profile.water_ride_credits,
"RC: {0}<br>DR: {1}<br>FR: {2}<br>WR: {3}",
getattr(profile, "coaster_credits", 0),
getattr(profile, "dark_ride_credits", 0),
getattr(profile, "flat_ride_credits", 0),
getattr(profile, "water_ride_credits", 0),
)
except UserProfile.DoesNotExist:
return "-"
@admin.action(description="Activate selected users")
def activate_users(self, request, queryset):
def activate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
queryset.update(is_active=True)
@admin.action(description="Deactivate selected users")
def deactivate_users(self, request, queryset):
def deactivate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
queryset.update(is_active=False)
@admin.action(description="Ban selected users")
def ban_users(self, request, queryset):
def ban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
from django.utils import timezone
queryset.update(is_banned=True, ban_date=timezone.now())
@admin.action(description="Unban selected users")
def unban_users(self, request, queryset):
def unban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
queryset.update(is_banned=False, ban_date=None, ban_reason="")
def save_model(self, request, obj, form, change):
def save_model(
self,
request: HttpRequest,
obj: User,
form: Any,
change: bool
) -> None:
creating = not obj.pk
super().save_model(request, obj, form, change)
if creating and obj.role != "USER":
# Ensure new user with role gets added to appropriate group
group = Group.objects.filter(name=obj.role).first()
if creating and getattr(obj, "role", "USER") != "USER":
group = Group.objects.filter(name=getattr(obj, "role", None)).first()
if group:
obj.groups.add(group)
obj.groups.add(group) # type: ignore[attr-defined]
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
class UserProfileAdmin(admin.ModelAdmin[UserProfile]):
list_display = (
"user",
"display_name",
@@ -235,7 +245,7 @@ class UserProfileAdmin(admin.ModelAdmin):
@admin.register(EmailVerification)
class EmailVerificationAdmin(admin.ModelAdmin):
class EmailVerificationAdmin(admin.ModelAdmin[EmailVerification]):
list_display = ("user", "created_at", "last_sent", "is_expired")
list_filter = ("created_at", "last_sent")
search_fields = ("user__username", "user__email", "token")
@@ -247,21 +257,21 @@ class EmailVerificationAdmin(admin.ModelAdmin):
)
@admin.display(description="Status")
def is_expired(self, obj):
def is_expired(self, obj: EmailVerification) -> str:
from django.utils import timezone
from datetime import timedelta
if timezone.now() - obj.last_sent > timedelta(days=1):
return format_html('<span style="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
if timezone.now() - getattr(obj, "last_sent", timezone.now()) > timedelta(days=1):
return format_html('<span style="color: red;">{}</span>', "Expired")
return format_html('<span style="color: green;">{}</span>', "Valid")
@admin.register(TopList)
class TopListAdmin(admin.ModelAdmin):
class TopListAdmin(admin.ModelAdmin[TopList]):
list_display = ("title", "user", "category", "created_at", "updated_at")
list_filter = ("category", "created_at", "updated_at")
search_fields = ("title", "user__username", "description")
inlines = [TopListItemInline]
inlines: list[type[admin.TabularInline[TopListItem]]] = [TopListItemInline]
fieldsets = (
(
@@ -277,7 +287,7 @@ class TopListAdmin(admin.ModelAdmin):
@admin.register(TopListItem)
class TopListItemAdmin(admin.ModelAdmin):
class TopListItemAdmin(admin.ModelAdmin[TopListItem]):
list_display = ("top_list", "content_type", "object_id", "rank")
list_filter = ("top_list__category", "rank")
search_fields = ("top_list__title", "notes")
@@ -290,7 +300,7 @@ class TopListItemAdmin(admin.ModelAdmin):
@admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin):
class PasswordResetAdmin(admin.ModelAdmin[PasswordReset]):
"""Admin interface for password reset tokens"""
list_display = (
@@ -341,20 +351,19 @@ class PasswordResetAdmin(admin.ModelAdmin):
)
@admin.display(description="Status", boolean=True)
def is_expired(self, obj):
"""Display expiration status with color coding"""
def is_expired(self, obj: PasswordReset) -> str:
from django.utils import timezone
if obj.used:
return format_html('<span style="color: blue;">Used</span>')
elif timezone.now() > obj.expires_at:
return format_html('<span style="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
if getattr(obj, "used", False):
return format_html('<span style="color: blue;">{}</span>', "Used")
elif timezone.now() > getattr(obj, "expires_at", timezone.now()):
return format_html('<span style="color: red;">{}</span>', "Expired")
return format_html('<span style="color: green;">{}</span>', "Valid")
def has_add_permission(self, request):
def has_add_permission(self, request: HttpRequest) -> bool:
"""Disable manual creation of password reset tokens"""
return False
def has_change_permission(self, request, obj=None):
def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
"""Allow viewing but restrict editing of password reset tokens"""
return getattr(request.user, "is_superuser", False)

View File

@@ -4,6 +4,7 @@ from apps.core.views.search import (
FilterFormView,
LocationSearchView,
LocationSuggestionsView,
AdvancedSearchView,
)
from apps.rides.views import RideSearchView
@@ -12,6 +13,7 @@ app_name = "search"
urlpatterns = [
path("parks/", AdaptiveSearchView.as_view(), name="search"),
path("parks/filters/", FilterFormView.as_view(), name="filter_form"),
path("advanced/", AdvancedSearchView.as_view(), name="advanced"),
path("rides/", RideSearchView.as_view(), name="ride_search"),
path("rides/results/", RideSearchView.as_view(), name="ride_search_results"),
# Location-aware search

View File

@@ -176,3 +176,43 @@ class LocationSuggestionsView(TemplateView):
return JsonResponse({"suggestions": suggestions})
except Exception as e:
return JsonResponse({"error": str(e)}, status=500)
class AdvancedSearchView(TemplateView):
"""Advanced search view with comprehensive filtering options for both parks and rides"""
template_name = "core/search/advanced.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Import here to avoid circular imports
from apps.parks.filters import ParkFilter
from apps.rides.filters import RideFilter
from apps.parks.models import Park
from apps.rides.models.rides import Ride
# Initialize filtersets for both parks and rides
park_filterset = ParkFilter(self.request.GET, queryset=Park.objects.all())
ride_filterset = RideFilter(self.request.GET, queryset=Ride.objects.all())
# Determine what type of search to show based on request parameters
search_type = self.request.GET.get('search_type', 'parks') # Default to parks
context.update({
'page_title': 'Advanced Search',
'page_description': 'Find exactly what you\'re looking for with our comprehensive search filters.',
'search_type': search_type,
'park_filters': park_filterset,
'ride_filters': ride_filterset,
'park_results': park_filterset.qs if search_type == 'parks' else None,
'ride_results': ride_filterset.qs if search_type == 'rides' else None,
'has_filters': bool(self.request.GET),
})
return context
def get_template_names(self):
"""Return appropriate template for HTMX requests"""
if hasattr(self.request, 'htmx') and self.request.htmx:
return ["core/search/partials/advanced_results.html"]
return [self.template_name]

View File

@@ -2,10 +2,11 @@
from django.db import models
# from django.contrib.gis.geos import Point # Disabled temporarily for setup
import pghistory
from apps.core.history import TrackedModel
@pghistory.track()
class ParkLocation(models.Model):
class ParkLocation(TrackedModel):
"""
Represents the geographic location and address of a park, with PostGIS support.
"""
@@ -53,15 +54,17 @@ class ParkLocation(models.Model):
@property
def latitude(self):
"""Return latitude from point field."""
if self.point:
return self.point.y
if self.point and ',' in self.point:
# Temporary string format: "longitude,latitude"
return float(self.point.split(',')[1])
return None
@property
def longitude(self):
"""Return longitude from point field."""
if self.point:
return self.point.x
if self.point and ',' in self.point:
# Temporary string format: "longitude,latitude"
return float(self.point.split(',')[0])
return None
@property
@@ -97,7 +100,9 @@ class ParkLocation(models.Model):
if not -180 <= longitude <= 180:
raise ValueError("Longitude must be between -180 and 180.")
self.point = Point(longitude, latitude, srid=4326)
# Temporarily store as string until PostGIS is enabled
self.point = f"{longitude},{latitude}"
# self.point = Point(longitude, latitude, srid=4326)
def distance_to(self, other_location):
"""
@@ -106,9 +111,26 @@ class ParkLocation(models.Model):
"""
if not self.point or not other_location.point:
return None
# Use geodetic distance calculation which returns meters, convert to km
distance_m = self.point.distance(other_location.point)
return distance_m / 1000.0
# Temporary implementation using Haversine formula
# TODO: Replace with PostGIS distance calculation when enabled
import math
lat1, lon1 = self.latitude, self.longitude
lat2, lon2 = other_location.latitude, other_location.longitude
if None in (lat1, lon1, lat2, lon2):
return None
# Haversine formula
R = 6371 # Earth's radius in kilometers
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat/2) * math.sin(dlat/2) +
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
math.sin(dlon/2) * math.sin(dlon/2))
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
return R * c
def __str__(self):
return f"Location for {self.park.name}"

View File

@@ -15,6 +15,7 @@ app_name = "parks"
urlpatterns = [
# Park views with autocomplete search
path("", views.ParkListView.as_view(), name="park_list"),
path("trending/", views.TrendingParksView.as_view(), name="trending"),
path("operators/", views.OperatorListView.as_view(), name="operator_list"),
path("create/", views.ParkCreateView.as_view(), name="park_create"),
# Add park button endpoint (moved before park detail pattern)

View File

@@ -29,6 +29,9 @@ from django.urls import reverse
from django.shortcuts import get_object_or_404, render
from decimal import InvalidOperation
from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.db.models import Count, Avg, Q
from django.utils import timezone
from datetime import timedelta
import requests
from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional, cast, Literal, Dict
@@ -224,6 +227,56 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
return JsonResponse({"error": "Geocoding failed"}, status=500)
class TrendingParksView(ListView):
"""View for displaying trending/popular parks"""
model = Park
template_name = "parks/trending_parks.html"
context_object_name = "parks"
paginate_by = 20
def get_queryset(self) -> QuerySet[Park]:
"""Get trending parks based on ride count, ratings, and recent activity"""
# For now, order by a combination of factors that indicate popularity:
# 1. Parks with more rides
# 2. Higher average ratings
# 3. More recent activity (reviews, photos, etc.)
thirty_days_ago = timezone.now() - timedelta(days=30)
return (
get_base_park_queryset()
.annotate(
recent_reviews=Count(
'reviews',
filter=Q(reviews__created_at__gte=thirty_days_ago)
),
recent_photos=Count(
'photos',
filter=Q(photos__created_at__gte=thirty_days_ago)
)
)
.order_by(
'-recent_reviews',
'-recent_photos',
'-ride_count',
'-average_rating'
)
)
def get_template_names(self) -> list[str]:
"""Return appropriate template for HTMX requests"""
if self.request.htmx:
return ["parks/partials/trending_parks.html"]
return [self.template_name]
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context.update({
'page_title': 'Trending Parks',
'page_description': 'Discover the most popular theme parks with recent activity and high ratings.'
})
return context
class ParkListView(HTMXFilterableMixin, ListView):
model = Park
template_name = "parks/enhanced_park_list.html"

View File

@@ -6,6 +6,7 @@ app_name = "rides"
urlpatterns = [
# Global list views
path("", views.RideListView.as_view(), name="global_ride_list"),
path("new/", views.NewRidesView.as_view(), name="new"),
# Global category views
path(
"roller-coasters/",

View File

@@ -302,6 +302,37 @@ class RideListView(ListView):
return context
class NewRidesView(ListView):
"""View for displaying recently added rides"""
model = Ride
template_name = "rides/new_rides.html"
context_object_name = "rides"
paginate_by = 20
def get_queryset(self):
"""Get recently added rides, ordered by creation date"""
return (
Ride.objects.all()
.select_related("park", "ride_model", "ride_model__manufacturer")
.prefetch_related("photos")
.order_by("-created_at")
)
def get_template_names(self):
"""Return appropriate template for HTMX requests"""
if hasattr(self.request, "htmx") and self.request.htmx:
return ["rides/partials/new_rides.html"]
return [self.template_name]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({
'page_title': 'New Attractions',
'page_description': 'Discover the latest rides and attractions added to theme parks around the world.'
})
return context
class SingleCategoryListView(ListView):
"""View for displaying rides of a specific category"""

View File

@@ -7,6 +7,7 @@ from datetime import timedelta
import sys
import warnings
from pathlib import Path
from typing import List
from decouple import config
# Suppress django-allauth deprecation warnings for dj_rest_auth compatibility
@@ -19,14 +20,14 @@ warnings.filterwarnings(
# Initialize environment variables with better defaults
DEBUG = config("DEBUG", default=True)
DEBUG = config("DEBUG", default=True, cast=bool)
SECRET_KEY = config("SECRET_KEY")
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()])
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=lambda v: [s.strip() for s in str(v).split(',') if s.strip()])
DATABASE_URL = config("DATABASE_URL")
CACHE_URL = config("CACHE_URL", default="locmem://")
EMAIL_URL = config("EMAIL_URL", default="console://")
REDIS_URL = config("REDIS_URL", default="redis://127.0.0.1:6379/1")
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()])
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=lambda v: [s.strip() for s in str(v).split(',') if s.strip()])
API_RATE_LIMIT_PER_MINUTE = config("API_RATE_LIMIT_PER_MINUTE", default=60)
API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000)
CACHE_MIDDLEWARE_SECONDS = config("CACHE_MIDDLEWARE_SECONDS", default=300)
@@ -55,7 +56,7 @@ SECRET_KEY = config("SECRET_KEY")
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS = config(
"CSRF_TRUSTED_ORIGINS", default="", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()]
"CSRF_TRUSTED_ORIGINS", default="", cast=lambda v: [s.strip() for s in str(v).split(',') if s.strip()]
)
# Application definition

View File

@@ -76,10 +76,33 @@ dev = [
[tool.pyright]
stubPath = "stubs"
typeCheckingMode = "basic"
include = ["."]
exclude = [
"**/node_modules",
"**/__pycache__",
"**/migrations",
"**/.venv",
"**/venv",
"**/.git",
"**/.hg",
"**/.tox",
"**/.nox",
]
typeCheckingMode = "strict"
reportIncompatibleMethodOverride = "error"
reportIncompatibleVariableOverride = "error"
reportGeneralTypeIssues = "error"
reportReturnType = "error"
reportMissingImports = "error"
reportMissingTypeStubs = "warning"
reportUndefinedVariable = "error"
reportUnusedImport = "warning"
reportUnusedVariable = "warning"
pythonVersion = "3.13"
[tool.pylance]
stubPath = "stubs"
[tool.uv.sources]
python-json-logger = { url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl" }

View File

@@ -1,22 +0,0 @@
{
"include": [
"."
],
"exclude": [
"**/node_modules",
"**/__pycache__",
"**/migrations"
],
"stubPath": "stubs",
"typeCheckingMode": "strict",
"reportIncompatibleMethodOverride": "error",
"reportIncompatibleVariableOverride": "error",
"reportGeneralTypeIssues": "error",
"reportReturnType": "error",
"reportMissingImports": "error",
"reportMissingTypeStubs": "warning",
"reportUndefinedVariable": "error",
"reportUnusedImport": "warning",
"reportUnusedVariable": "warning",
"pythonVersion": "3.13"
}

View File

@@ -48,7 +48,6 @@
<!-- Preload Critical Resources -->
{% block critical_resources %}
<link rel="preload" href="{% static 'css/tailwind.css' %}" as="style" />
<link rel="preload" href="{% static 'js/alpine.min.js' %}?v={{ version|default:'1.0' }}" as="script" />
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" as="style" />
{% endblock %}
@@ -62,10 +61,10 @@
/>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js" integrity="sha384-yWakaGAFicqusuwOYEmoRjLNOC+6OFsdmwC2lbGQaRELtuVEqNzt11c2J711DeCZ" crossorigin="anonymous"></script>
<!-- Alpine.js (must load after components) -->
<script defer src="{% static 'js/alpine.min.js' %}?v={{ version|default:'1.0' }}"></script>
<script src="//unpkg.com/alpinejs" defer></script>
<!-- Tailwind CSS -->
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
@@ -173,40 +172,60 @@
<c-toast_container />
<!-- AlpineJS Global Configuration (Compliant with HTMX + AlpineJS Only Rule) -->
<div x-data="{}" x-init="
// Configure HTMX globally
htmx.config.globalViewTransitions = true;
// Initialize Alpine stores
Alpine.store('app', {
user: null,
theme: localStorage.getItem('theme') || 'system',
searchQuery: '',
notifications: []
});
Alpine.store('toast', {
toasts: [],
show(message, type = 'info', duration = 5000) {
const id = Date.now() + Math.random();
const toast = { id, message, type, visible: true, progress: 100 };
this.toasts.push(toast);
if (duration > 0) {
setTimeout(() => this.hide(id), duration);
<script>
document.addEventListener('alpine:init', () => {
// Configure HTMX 2.x globally with proper defaults
htmx.config.globalViewTransitions = true;
// HTMX 2.x Migration: Maintain 1.x behavior for smooth scrolling
htmx.config.scrollBehavior = 'smooth';
// HTMX 2.x Migration: Keep DELETE requests using form-encoded body (like 1.x)
htmx.config.methodsThatUseUrlParams = ["get"];
// HTMX 2.x Migration: Allow cross-domain requests (like 1.x)
htmx.config.selfRequestsOnly = false;
// Enhanced HTMX event handling for better UX
document.body.addEventListener('htmx:configRequest', function(evt) {
// Add CSRF token to all HTMX requests
const csrfToken = document.querySelector('meta[name="csrf-token"]');
if (csrfToken) {
evt.detail.headers['X-CSRFToken'] = csrfToken.getAttribute('content');
}
return id;
},
hide(id) {
const toast = this.toasts.find(t => t.id === id);
if (toast) {
toast.visible = false;
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, 300);
});
// Initialize Alpine stores
Alpine.store('app', {
user: null,
theme: localStorage.getItem('theme') || 'system',
searchQuery: '',
notifications: []
});
Alpine.store('toast', {
toasts: [],
show(message, type = 'info', duration = 5000) {
const id = Date.now() + Math.random();
const toast = { id, message, type, visible: true, progress: 100 };
this.toasts.push(toast);
if (duration > 0) {
setTimeout(() => this.hide(id), duration);
}
return id;
},
hide(id) {
const toast = this.toasts.find(t => t.id === id);
if (toast) {
toast.visible = false;
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, 300);
}
}
}
});
});
" style="display: none;"></div>
</script>
{% block extra_js %}{% endblock %}
</body>

View File

@@ -51,9 +51,9 @@
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-thrill-primary to-purple-500 transition-all duration-300 group-hover:w-full"></span>
</a>
<a href="{% url 'rides:list' %}"
<a href="{% url 'rides:global_ride_list' %}"
class="nav-link group relative"
hx-get="{% url 'rides:list' %}"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
<i class="fas fa-rocket mr-2 text-thrill-secondary"></i>
@@ -367,7 +367,7 @@
<span class="font-medium">Parks</span>
</a>
<a href="{% url 'rides:ride_list' %}"
<a href="{% url 'rides:global_ride_list' %}"
class="flex items-center p-3 rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-700/50 transition-colors"
@click="isOpen = false">
<i class="fas fa-rocket mr-3 text-thrill-secondary"></i>

View File

@@ -98,46 +98,67 @@
</div>
<!-- Featured Parks Grid -->
<div class="grid-auto-fit-lg"
hx-get="/api/parks/featured/"
hx-trigger="revealed"
hx-swap="innerHTML">
<!-- Loading Skeletons -->
<div class="grid-auto-fit-lg">
<!-- Static placeholder content -->
<div class="card hover-lift">
<div class="loading-skeleton aspect-video rounded-t-2xl"></div>
<div class="p-6 space-y-4">
<div class="loading-skeleton h-6 w-3/4 rounded"></div>
<div class="loading-skeleton h-4 w-full rounded"></div>
<div class="loading-skeleton h-4 w-2/3 rounded"></div>
<div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-primary to-purple-500 flex items-center justify-center">
<i class="fas fa-map-marked-alt text-4xl text-white"></i>
</div>
<div class="p-6">
<h3 class="text-xl font-bold mb-2">Explore Amazing Parks</h3>
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
Discover incredible theme parks from around the world with detailed guides and insider tips.
</p>
<div class="flex justify-between items-center">
<div class="loading-skeleton h-6 w-20 rounded-full"></div>
<div class="loading-skeleton h-8 w-24 rounded-lg"></div>
<span class="badge badge-primary">Featured</span>
<button class="btn-primary btn-sm"
hx-get="/parks/"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
View All
</button>
</div>
</div>
</div>
<div class="card hover-lift">
<div class="loading-skeleton aspect-video rounded-t-2xl"></div>
<div class="p-6 space-y-4">
<div class="loading-skeleton h-6 w-3/4 rounded"></div>
<div class="loading-skeleton h-4 w-full rounded"></div>
<div class="loading-skeleton h-4 w-2/3 rounded"></div>
<div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-secondary to-red-500 flex items-center justify-center">
<i class="fas fa-rocket text-4xl text-white"></i>
</div>
<div class="p-6">
<h3 class="text-xl font-bold mb-2">Thrilling Rides</h3>
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
From heart-pounding roller coasters to magical dark rides, find your next adventure.
</p>
<div class="flex justify-between items-center">
<div class="loading-skeleton h-6 w-20 rounded-full"></div>
<div class="loading-skeleton h-8 w-24 rounded-lg"></div>
<span class="badge badge-secondary">Popular</span>
<button class="btn-secondary btn-sm"
hx-get="/rides/"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
Explore
</button>
</div>
</div>
</div>
<div class="card hover-lift">
<div class="loading-skeleton aspect-video rounded-t-2xl"></div>
<div class="p-6 space-y-4">
<div class="loading-skeleton h-6 w-3/4 rounded"></div>
<div class="loading-skeleton h-4 w-full rounded"></div>
<div class="loading-skeleton h-4 w-2/3 rounded"></div>
<div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-success to-teal-500 flex items-center justify-center">
<i class="fas fa-search text-4xl text-white"></i>
</div>
<div class="p-6">
<h3 class="text-xl font-bold mb-2">Advanced Search</h3>
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
Find exactly what you're looking for with our powerful search and filtering tools.
</p>
<div class="flex justify-between items-center">
<div class="loading-skeleton h-6 w-20 rounded-full"></div>
<div class="loading-skeleton h-8 w-24 rounded-lg"></div>
<span class="badge badge-success">Tools</span>
<button class="btn-success btn-sm"
hx-get="/search/"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
Search
</button>
</div>
</div>
</div>

View File

@@ -3,7 +3,8 @@
{% if location.id %}data-location-id="{{ location.id }}"{% endif %}
{% if location.type %}data-location-type="{{ location.type }}"{% endif %}
{% if location.latitude and location.longitude %}data-lat="{{ location.latitude }}" data-lng="{{ location.longitude }}"{% endif %}
{% if clickable %}onclick="{{ onclick_action|default:'window.location.href=\''|add:location.get_absolute_url|add:'\'' }}"{% endif %}>
x-data="locationCard()"
{% if clickable %}@click="handleCardClick('{{ location.get_absolute_url }}')"{% endif %}>
<!-- Card Header -->
<div class="flex items-start justify-between mb-3">
@@ -69,7 +70,7 @@
{% endif %}
{% if show_map_action %}
<button onclick="showOnMap('{{ location.type }}', {{ location.id }})"
<button @click="showOnMap('{{ location.type }}', {{ location.id }})"
class="px-3 py-2 text-sm text-green-600 border border-green-600 rounded-lg hover:bg-green-50 dark:hover:bg-green-900 transition-colors"
title="Show on map">
<i class="fas fa-map-marker-alt"></i>
@@ -77,7 +78,7 @@
{% endif %}
{% if show_trip_action %}
<button onclick="addToTrip({{ location|safe }})"
<button @click="addToTrip({{ location|safe }})"
class="px-3 py-2 text-sm text-purple-600 border border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900 transition-colors"
title="Add to trip">
<i class="fas fa-plus"></i>
@@ -297,50 +298,55 @@ This would be in templates/maps/partials/park_card_content.html
}
</style>
<!-- Location Card JavaScript -->
<script>
// Global functions for location card actions
window.showOnMap = function(type, id) {
// Emit custom event for map integration
const event = new CustomEvent('showLocationOnMap', {
detail: { type, id }
});
document.dispatchEvent(event);
};
window.addToTrip = function(locationData) {
// Emit custom event for trip integration
const event = new CustomEvent('addLocationToTrip', {
detail: locationData
});
document.dispatchEvent(event);
};
// Handle location card selection
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('click', function(e) {
const card = e.target.closest('.location-card');
if (card && card.dataset.locationId) {
// Remove previous selections
document.addEventListener('alpine:init', () => {
Alpine.data('locationCard', () => ({
selected: false,
init() {
// Listen for card selection events
this.$el.addEventListener('click', (e) => {
if (this.$el.dataset.locationId) {
this.handleCardSelection();
}
});
},
handleCardClick(url) {
if (url) {
window.location.href = url;
}
},
showOnMap(type, id) {
// Emit custom event for map integration using AlpineJS approach
this.$dispatch('showLocationOnMap', { type, id });
},
addToTrip(locationData) {
// Emit custom event for trip integration using AlpineJS approach
this.$dispatch('addLocationToTrip', locationData);
},
handleCardSelection() {
// Remove previous selections using AlpineJS approach
document.querySelectorAll('.location-card.selected').forEach(c => {
c.classList.remove('selected');
});
// Add selection to clicked card
card.classList.add('selected');
// Add selection to this card
this.$el.classList.add('selected');
this.selected = true;
// Emit selection event
const event = new CustomEvent('locationCardSelected', {
detail: {
id: card.dataset.locationId,
type: card.dataset.locationType,
lat: card.dataset.lat,
lng: card.dataset.lng,
element: card
}
// Emit selection event using AlpineJS $dispatch
this.$dispatch('locationCardSelected', {
id: this.$el.dataset.locationId,
type: this.$el.dataset.locationType,
lat: this.$el.dataset.lat,
lng: this.$el.dataset.lng,
element: this.$el
});
document.dispatchEvent(event);
}
});
}));
});
</script>
</script>

View File

@@ -145,7 +145,7 @@
{% endblock %}
{% block content %}
<div class="container max-w-6xl px-4 py-6 mx-auto">
<div class="container max-w-6xl px-4 py-6 mx-auto" x-data="moderationDashboard()" @retry-load="retryLoad()">
<div id="dashboard-content" class="relative transition-all duration-200">
{% block moderation_content %}
{% include "moderation/partials/dashboard_content.html" %}
@@ -169,7 +169,7 @@
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()">
@click="$dispatch('retry-load')">
<i class="mr-2 fas fa-sync-alt"></i>
Retry
</button>
@@ -180,133 +180,156 @@
{% endblock %}
{% block extra_js %}
<!-- AlpineJS Moderation Dashboard Component (HTMX + AlpineJS Only) -->
<div x-data="{
showLoading: false,
errorMessage: null,
showError(message) {
this.errorMessage = message;
}
}"
@htmx:before-request="showLoading = true"
@htmx:after-request="showLoading = false"
@htmx:response-error="showError('Failed to load content')"
style="display: none;">
<!-- Dashboard functionality handled by AlpineJS + HTMX -->
</div>
<script>
// HTMX Configuration
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
document.addEventListener('alpine:init', () => {
// Moderation Dashboard Component
Alpine.data('moderationDashboard', () => ({
showLoading: false,
errorMessage: null,
init() {
// HTMX Configuration
this.setupHTMXConfig();
this.setupEventListeners();
this.setupSearchDebouncing();
this.setupInfiniteScroll();
this.setupKeyboardNavigation();
},
setupHTMXConfig() {
document.body.addEventListener('htmx:configRequest', (evt) => {
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
},
setupEventListeners() {
// Enhanced HTMX Event Handlers
document.body.addEventListener('htmx:beforeRequest', (evt) => {
if (evt.detail.target.id === 'dashboard-content') {
this.showLoadingState();
}
});
// 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'),
document.body.addEventListener('htmx:afterOnLoad', (evt) => {
if (evt.detail.target.id === 'dashboard-content') {
this.hideLoadingState();
this.resetFocus(evt.detail.target);
}
});
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;
document.body.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.target.id === 'dashboard-content') {
this.showErrorState(evt.detail.error);
}
});
},
showLoadingState() {
const content = this.$el.querySelector('#dashboard-content');
if (content) {
content.setAttribute('aria-busy', 'true');
content.style.opacity = '0';
}
});
}
const errorState = this.$el.querySelector('#error-state');
if (errorState) {
errorState.classList.add('hidden');
}
},
hideLoadingState() {
const content = this.$el.querySelector('#dashboard-content');
if (content) {
content.setAttribute('aria-busy', 'false');
content.style.opacity = '1';
}
},
showErrorState(message) {
const errorState = this.$el.querySelector('#error-state');
const errorMessage = this.$el.querySelector('#error-message');
if (errorState) {
errorState.classList.remove('hidden');
}
if (errorMessage) {
errorMessage.textContent = message || 'There was a problem loading the content. Please try again.';
errorMessage.setAttribute('role', 'alert');
}
},
resetFocus(target) {
const firstFocusable = target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) {
firstFocusable.focus();
}
},
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
setupSearchDebouncing() {
const searchInputs = this.$el.querySelectorAll('[data-search]');
searchInputs.forEach(input => {
const originalSearch = () => {
htmx.trigger(input, 'input');
};
const debouncedSearch = this.debounce(originalSearch, 300);
input.addEventListener('input', (e) => {
e.preventDefault();
debouncedSearch();
});
});
},
setupInfiniteScroll() {
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);
const infiniteScrollElements = this.$el.querySelectorAll('[data-infinite-scroll]');
infiniteScrollElements.forEach(el => observer.observe(el));
},
setupKeyboardNavigation() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const openModals = this.$el.querySelectorAll('[x-show="showNotes"]');
openModals.forEach(modal => {
const alpineData = modal.__x?.$data;
if (alpineData && alpineData.showNotes) {
alpineData.showNotes = false;
}
});
}
});
},
retryLoad() {
window.location.reload();
}
}));
});
</script>
{% endblock %}

View File

@@ -199,24 +199,31 @@
</div>
<script>
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful) {
const path = evt.detail.requestConfig.path;
let event;
if (path.includes('approve')) {
event = new CustomEvent('submission-approved');
} else if (path.includes('reject')) {
event = new CustomEvent('submission-rejected');
} else if (path.includes('escalate')) {
event = new CustomEvent('submission-escalated');
} else if (path.includes('edit')) {
event = new CustomEvent('submission-updated');
}
if (event) {
window.dispatchEvent(event);
}
document.addEventListener('alpine:init', () => {
Alpine.data('moderationDashboard', () => ({
init() {
// Handle HTMX events using AlpineJS approach
document.body.addEventListener('htmx:afterRequest', (evt) => {
if (evt.detail.successful) {
const path = evt.detail.requestConfig.path;
let eventName;
if (path.includes('approve')) {
eventName = 'submission-approved';
} else if (path.includes('reject')) {
eventName = 'submission-rejected';
} else if (path.includes('escalate')) {
eventName = 'submission-escalated';
} else if (path.includes('edit')) {
eventName = 'submission-updated';
}
if (eventName) {
this.$dispatch(eventName);
}
}
});
}
});
}));
});
</script>

View File

@@ -1,9 +1,12 @@
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600"
style="max-height: 240px; overflow-y: auto;"
x-data="designerSearchResults('{{ submission_id }}')"
@click.outside="clearResults()">
{% if designers %}
{% for designer in designers %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectDesignerForSubmission('{{ designer.id }}', '{{ designer.name|escapejs }}', '{{ submission_id }}')">
@click="selectDesigner('{{ designer.id }}', '{{ designer.name|escapejs }}')">
{{ designer.name }}
</button>
{% endfor %}
@@ -19,49 +22,49 @@
</div>
<script>
function selectDesignerForSubmission(id, name, submissionId) {
// Debug logging
console.log('Selecting designer:', {id, name, submissionId});
// Find elements
const designerInput = document.querySelector(`#designer-input-${submissionId}`);
const searchInput = document.querySelector(`#designer-search-${submissionId}`);
const resultsDiv = document.querySelector(`#designer-search-results-${submissionId}`);
// Debug logging
console.log('Found elements:', {
designerInput: designerInput?.id,
searchInput: searchInput?.id,
resultsDiv: resultsDiv?.id
});
// Update hidden input
if (designerInput) {
designerInput.value = id;
console.log('Updated designer input value:', designerInput.value);
}
// Update search input
if (searchInput) {
searchInput.value = name;
console.log('Updated search input value:', searchInput.value);
}
// Clear results
if (resultsDiv) {
resultsDiv.innerHTML = '';
console.log('Cleared results div');
}
}
document.addEventListener('alpine:init', () => {
Alpine.data('designerSearchResults', (submissionId) => ({
submissionId: submissionId,
// Close search results when clicking outside
document.addEventListener('click', function(e) {
const searchResults = document.querySelectorAll('[id^="designer-search-results-"]');
searchResults.forEach(function(resultsDiv) {
const searchInput = document.querySelector(`#designer-search-${resultsDiv.id.split('-').pop()}`);
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
resultsDiv.innerHTML = '';
selectDesigner(id, name) {
// Debug logging
console.log('Selecting designer:', {id, name, submissionId: this.submissionId});
// Find elements using AlpineJS approach
const designerInput = document.querySelector(`#designer-input-${this.submissionId}`);
const searchInput = document.querySelector(`#designer-search-${this.submissionId}`);
const resultsDiv = document.querySelector(`#designer-search-results-${this.submissionId}`);
// Debug logging
console.log('Found elements:', {
designerInput: designerInput?.id,
searchInput: searchInput?.id,
resultsDiv: resultsDiv?.id
});
// Update hidden input
if (designerInput) {
designerInput.value = id;
console.log('Updated designer input value:', designerInput.value);
}
// Update search input
if (searchInput) {
searchInput.value = name;
console.log('Updated search input value:', searchInput.value);
}
// Clear results
this.clearResults();
},
clearResults() {
const resultsDiv = document.querySelector(`#designer-search-results-${this.submissionId}`);
if (resultsDiv) {
resultsDiv.innerHTML = '';
console.log('Cleared results div');
}
}
});
}));
});
</script>

View File

@@ -19,30 +19,60 @@
}
</style>
<div class="p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<div class="p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
x-data="locationWidget({
submissionId: '{{ submission.id }}',
initialData: {
city: '{{ submission.changes.city|default:"" }}',
state: '{{ submission.changes.state|default:"" }}',
country: '{{ submission.changes.country|default:"" }}',
postal_code: '{{ submission.changes.postal_code|default:"" }}',
street_address: '{{ submission.changes.street_address|default:"" }}',
latitude: '{{ submission.changes.latitude|default:"" }}',
longitude: '{{ submission.changes.longitude|default:"" }}'
}
})"
x-init="init()">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-300">Location</h3>
<div class="location-widget" id="locationWidget-{{ submission.id }}">
<div class="location-widget">
{# Search Form #}
<div class="relative mb-4">
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Search Location
</label>
<input type="text"
id="locationSearch-{{ submission.id }}"
x-model="searchQuery"
@input.debounce.300ms="handleSearch()"
@click.outside="showSearchResults = false"
class="relative w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
placeholder="Search for a location..."
autocomplete="off"
style="z-index: 10;">
<div id="searchResults-{{ submission.id }}"
<div x-show="showSearchResults"
x-transition
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
class="w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
<template x-for="(result, index) in searchResults" :key="index">
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
@click="selectLocation(result)">
<div class="font-medium text-gray-900 dark:text-white" x-text="result.display_name || result.name || ''"></div>
<div class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="result.address?.city ? result.address.city + ', ' : ''"></span>
<span x-text="result.address?.country || ''"></span>
</div>
</div>
</template>
<div x-show="searchResults.length === 0 && searchQuery.length > 0"
class="p-2 text-gray-500 dark:text-gray-400">
No results found
</div>
</div>
</div>
{# Map Container #}
<div class="relative mb-4" style="z-index: 1;">
<div id="locationMap-{{ submission.id }}"
<div x-ref="mapContainer"
class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
</div>
@@ -54,9 +84,8 @@
</label>
<input type="text"
name="street_address"
id="streetAddress-{{ submission.id }}"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
value="{{ submission.changes.street_address }}">
x-model="formData.street_address"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -64,9 +93,8 @@
</label>
<input type="text"
name="city"
id="city-{{ submission.id }}"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
value="{{ submission.changes.city }}">
x-model="formData.city"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -74,9 +102,8 @@
</label>
<input type="text"
name="state"
id="state-{{ submission.id }}"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
value="{{ submission.changes.state }}">
x-model="formData.state"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -84,9 +111,8 @@
</label>
<input type="text"
name="country"
id="country-{{ submission.id }}"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
value="{{ submission.changes.country }}">
x-model="formData.country"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -94,143 +120,140 @@
</label>
<input type="text"
name="postal_code"
id="postalCode-{{ submission.id }}"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
value="{{ submission.changes.postal_code }}">
x-model="formData.postal_code"
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
</div>
</div>
{# Hidden Coordinate Fields #}
<div class="hidden">
<input type="hidden" name="latitude" id="latitude-{{ submission.id }}" value="{{ submission.changes.latitude }}">
<input type="hidden" name="longitude" id="longitude-{{ submission.id }}" value="{{ submission.changes.longitude }}">
<input type="hidden" name="latitude" x-model="formData.latitude">
<input type="hidden" name="longitude" x-model="formData.longitude">
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
let maps = {};
let markers = {};
const searchInput = document.getElementById('locationSearch-{{ submission.id }}');
const searchResults = document.getElementById('searchResults-{{ submission.id }}');
let searchTimeout;
document.addEventListener('alpine:init', () => {
Alpine.data('locationWidget', (config) => ({
submissionId: config.submissionId,
formData: { ...config.initialData },
searchQuery: '',
searchResults: [],
showSearchResults: false,
map: null,
marker: null,
// Initialize form fields with existing values
const fields = {
city: '{{ submission.changes.city|default:"" }}',
state: '{{ submission.changes.state|default:"" }}',
country: '{{ submission.changes.country|default:"" }}',
postal_code: '{{ submission.changes.postal_code|default:"" }}',
street_address: '{{ submission.changes.street_address|default:"" }}',
latitude: '{{ submission.changes.latitude|default:"" }}',
longitude: '{{ submission.changes.longitude|default:"" }}'
};
Object.entries(fields).forEach(([field, value]) => {
const element = document.getElementById(`${field}-{{ submission.id }}`);
if (element) {
element.value = value;
}
});
// Set initial search input value if location exists
if (fields.street_address || fields.city) {
const parts = [
fields.street_address,
fields.city,
fields.state,
fields.country
].filter(Boolean);
searchInput.value = parts.join(', ');
}
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
if (!value) return null;
try {
const rounded = Number(value).toFixed(decimalPlaces);
const strValue = rounded.replace('.', '').replace('-', '');
const strippedValue = strValue.replace(/0+$/, '');
if (strippedValue.length > maxDigits) {
return Number(Number(value).toFixed(decimalPlaces - 1));
init() {
// Set initial search query if location exists
if (this.formData.street_address || this.formData.city) {
const parts = [
this.formData.street_address,
this.formData.city,
this.formData.state,
this.formData.country
].filter(Boolean);
this.searchQuery = parts.join(', ');
}
return rounded;
} catch (error) {
console.error('Coordinate normalization failed:', error);
return null;
}
}
function validateCoordinates(lat, lng) {
const normalizedLat = normalizeCoordinate(lat, 9, 6);
const normalizedLng = normalizeCoordinate(lng, 10, 6);
// Initialize map when component is ready
this.$nextTick(() => {
this.initMap();
});
},
if (normalizedLat === null || normalizedLng === null) {
throw new Error('Invalid coordinate format');
}
const parsedLat = parseFloat(normalizedLat);
const parsedLng = parseFloat(normalizedLng);
if (parsedLat < -90 || parsedLat > 90) {
throw new Error('Latitude must be between -90 and 90 degrees.');
}
if (parsedLng < -180 || parsedLng > 180) {
throw new Error('Longitude must be between -180 and 180 degrees.');
}
return { lat: normalizedLat, lng: normalizedLng };
}
function initMap() {
const submissionId = '{{ submission.id }}';
const mapId = `locationMap-${submissionId}`;
const mapContainer = document.getElementById(mapId);
if (!mapContainer) {
console.error(`Map container ${mapId} not found`);
return;
}
// If map already exists, remove it
if (maps[submissionId]) {
maps[submissionId].remove();
delete maps[submissionId];
delete markers[submissionId];
}
// Create new map
maps[submissionId] = L.map(mapId);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(maps[submissionId]);
// Initialize with existing coordinates if available
const initialLat = fields.latitude;
const initialLng = fields.longitude;
if (initialLat && initialLng) {
normalizeCoordinate(value, maxDigits, decimalPlaces) {
if (!value) return null;
try {
const normalized = validateCoordinates(initialLat, initialLng);
maps[submissionId].setView([normalized.lat, normalized.lng], 13);
addMarker(normalized.lat, normalized.lng);
} catch (error) {
console.error('Invalid initial coordinates:', error);
maps[submissionId].setView([0, 0], 2);
}
} else {
maps[submissionId].setView([0, 0], 2);
}
// Handle map clicks - HTMX version
maps[submissionId].on('click', function(e) {
try {
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
const rounded = Number(value).toFixed(decimalPlaces);
const strValue = rounded.replace('.', '').replace('-', '');
const strippedValue = strValue.replace(/0+$/, '');
// Create a temporary form for HTMX request
if (strippedValue.length > maxDigits) {
return Number(Number(value).toFixed(decimalPlaces - 1));
}
return rounded;
} catch (error) {
console.error('Coordinate normalization failed:', error);
return null;
}
},
validateCoordinates(lat, lng) {
const normalizedLat = this.normalizeCoordinate(lat, 9, 6);
const normalizedLng = this.normalizeCoordinate(lng, 10, 6);
if (normalizedLat === null || normalizedLng === null) {
throw new Error('Invalid coordinate format');
}
const parsedLat = parseFloat(normalizedLat);
const parsedLng = parseFloat(normalizedLng);
if (parsedLat < -90 || parsedLat > 90) {
throw new Error('Latitude must be between -90 and 90 degrees.');
}
if (parsedLng < -180 || parsedLng > 180) {
throw new Error('Longitude must be between -180 and 180 degrees.');
}
return { lat: normalizedLat, lng: normalizedLng };
},
initMap() {
if (!this.$refs.mapContainer) {
console.error('Map container not found');
return;
}
// If map already exists, remove it
if (this.map) {
this.map.remove();
this.map = null;
this.marker = null;
}
// Create new map
this.map = L.map(this.$refs.mapContainer);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(this.map);
// Initialize with existing coordinates if available
if (this.formData.latitude && this.formData.longitude) {
try {
const normalized = this.validateCoordinates(this.formData.latitude, this.formData.longitude);
this.map.setView([normalized.lat, normalized.lng], 13);
this.addMarker(normalized.lat, normalized.lng);
} catch (error) {
console.error('Invalid initial coordinates:', error);
this.map.setView([0, 0], 2);
}
} else {
this.map.setView([0, 0], 2);
}
// Handle map clicks
this.map.on('click', (e) => {
this.handleMapClick(e.latlng.lat, e.latlng.lng);
});
},
addMarker(lat, lng) {
if (this.marker) {
this.marker.remove();
}
this.marker = L.marker([lat, lng]).addTo(this.map);
this.map.setView([lat, lng], 13);
},
async handleMapClick(lat, lng) {
try {
const normalized = this.validateCoordinates(lat, lng);
// Use HTMX for reverse geocoding
const tempForm = document.createElement('form');
tempForm.style.display = 'none';
tempForm.setAttribute('hx-get', '/parks/search/reverse-geocode/');
@@ -241,15 +264,14 @@ document.addEventListener('DOMContentLoaded', function() {
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add event listener for HTMX response
tempForm.addEventListener('htmx:afterRequest', function(event) {
tempForm.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.successful) {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.error) {
throw new Error(data.error);
}
updateLocation(normalized.lat, normalized.lng, data);
this.updateLocation(normalized.lat, normalized.lng, data);
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
@@ -258,7 +280,6 @@ document.addEventListener('DOMContentLoaded', function() {
console.error('Geocoding request failed');
alert('Failed to update location. Please try again.');
}
// Clean up temporary form
document.body.removeChild(tempForm);
});
@@ -269,102 +290,50 @@ document.addEventListener('DOMContentLoaded', function() {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
});
}
},
function addMarker(lat, lng) {
const submissionId = '{{ submission.id }}';
if (markers[submissionId]) {
markers[submissionId].remove();
}
markers[submissionId] = L.marker([lat, lng]).addTo(maps[submissionId]);
maps[submissionId].setView([lat, lng], 13);
}
updateLocation(lat, lng, data) {
try {
const normalized = this.validateCoordinates(lat, lng);
// Update coordinates
this.formData.latitude = normalized.lat;
this.formData.longitude = normalized.lng;
// Update marker
this.addMarker(normalized.lat, normalized.lng);
// Update form fields with English names where available
const address = data.address || {};
this.formData.street_address = `${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
this.formData.city = address.city || address.town || address.village || '';
this.formData.state = address.state || address.region || '';
this.formData.country = address.country || '';
this.formData.postal_code = address.postcode || '';
function updateLocation(lat, lng, data) {
try {
const normalized = validateCoordinates(lat, lng);
const submissionId = '{{ submission.id }}';
// Update coordinates
document.getElementById(`latitude-${submissionId}`).value = normalized.lat;
document.getElementById(`longitude-${submissionId}`).value = normalized.lng;
// Update marker
addMarker(normalized.lat, normalized.lng);
// Update form fields with English names where available
const address = data.address || {};
document.getElementById(`streetAddress-${submissionId}`).value =
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
document.getElementById(`city-${submissionId}`).value =
address.city || address.town || address.village || '';
document.getElementById(`state-${submissionId}`).value =
address.state || address.region || '';
document.getElementById(`country-${submissionId}`).value = address.country || '';
document.getElementById(`postalCode-${submissionId}`).value = address.postcode || '';
// Update search input
const locationString-3 = [
document.getElementById(`streetAddress-${submissionId}`).value,
document.getElementById(`city-${submissionId}`).value,
document.getElementById(`state-${submissionId}`).value,
document.getElementById(`country-${submissionId}`).value
].filter(Boolean).join(', ');
searchInput.value = locationString;
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
}
function selectLocation(result) {
if (!result) return;
try {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
if (isNaN(lat) || isNaN(lon)) {
throw new Error('Invalid coordinates in search result');
// Update search input
const locationParts = [
this.formData.street_address,
this.formData.city,
this.formData.state,
this.formData.country
].filter(Boolean);
this.searchQuery = locationParts.join(', ');
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
const normalized = validateCoordinates(lat, lon);
// Create a normalized address object
const address = {
name: result.display_name || result.name || '',
address: {
house_number: result.address ? result.address.house_number : '',
road: result.address ? (result.address.road || result.address.street) : '',
city: result.address ? (result.address.city || result.address.town || result.address.village) : '',
state: result.address ? (result.address.state || result.address.region) : '',
country: result.address ? result.address.country : '',
postcode: result.address ? result.address.postcode : ''
}
};
updateLocation(normalized.lat, normalized.lng, address);
searchResults.classList.add('hidden');
searchInput.value = address.name;
} catch (error) {
console.error('Location selection failed:', error);
alert(error.message || 'Failed to select location. Please try again.');
}
}
},
// Handle location search - HTMX version
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (!query) {
searchResults.classList.add('hidden');
return;
}
handleSearch() {
const query = this.searchQuery.trim();
if (!query) {
this.showSearchResults = false;
return;
}
searchTimeout = setTimeout(function() {
// Create a temporary form for HTMX request
// Use HTMX for location search
const tempForm = document.createElement('form');
tempForm.style.display = 'none';
tempForm.setAttribute('hx-get', '/parks/search/location/');
@@ -374,88 +343,69 @@ document.addEventListener('DOMContentLoaded', function() {
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add event listener for HTMX response
tempForm.addEventListener('htmx:afterRequest', function(event) {
tempForm.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.successful) {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.results && data.results.length > 0) {
const resultsHtml = data.results.map((result, index) => `
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
data-result-index="${index}">
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''}
</div>
</div>
`).join('');
searchResults.innerHTML = resultsHtml;
searchResults.classList.remove('hidden');
// Store results data
searchResults.dataset.results = JSON.stringify(data.results);
// Add click handlers
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
el.addEventListener('click', function() {
const results = JSON.parse(searchResults.dataset.results);
const result = results[this.dataset.resultIndex];
selectLocation(result);
});
});
this.searchResults = data.results;
this.showSearchResults = true;
} else {
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
searchResults.classList.remove('hidden');
this.searchResults = [];
this.showSearchResults = true;
}
} catch (error) {
console.error('Search failed:', error);
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
searchResults.classList.remove('hidden');
this.searchResults = [];
this.showSearchResults = false;
}
} else {
console.error('Search request failed');
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
searchResults.classList.remove('hidden');
this.searchResults = [];
this.showSearchResults = false;
}
// Clean up temporary form
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}, 300);
});
},
// Hide search results when clicking outside
document.addEventListener('click', function(e) {
if (!searchResults.contains(e.target) && e.target !== searchInput) {
searchResults.classList.add('hidden');
}
});
// Initialize map when the element becomes visible
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
const mapContainer = document.getElementById(`locationMap-{{ submission.id }}`);
if (mapContainer && window.getComputedStyle(mapContainer).display !== 'none') {
initMap();
observer.disconnect();
selectLocation(result) {
if (!result) return;
try {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
if (isNaN(lat) || isNaN(lon)) {
throw new Error('Invalid coordinates in search result');
}
const normalized = this.validateCoordinates(lat, lon);
// Create a normalized address object
const address = {
name: result.display_name || result.name || '',
address: {
house_number: result.address ? result.address.house_number : '',
road: result.address ? (result.address.road || result.address.street) : '',
city: result.address ? (result.address.city || result.address.town || result.address.village) : '',
state: result.address ? (result.address.state || result.address.region) : '',
country: result.address ? result.address.country : '',
postcode: result.address ? result.address.postcode : ''
}
};
this.updateLocation(normalized.lat, normalized.lng, address);
this.showSearchResults = false;
this.searchQuery = address.name;
} catch (error) {
console.error('Location selection failed:', error);
alert(error.message || 'Failed to select location. Please try again.');
}
});
});
const mapContainer = document.getElementById(`locationMap-{{ submission.id }}`);
if (mapContainer) {
observer.observe(mapContainer.parentElement.parentElement, { attributes: true });
// Also initialize immediately if the container is already visible
if (window.getComputedStyle(mapContainer).display !== 'none') {
initMap();
}
}
}));
});
</script>

View File

@@ -1,9 +1,12 @@
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600"
style="max-height: 240px; overflow-y: auto;"
x-data="manufacturerSearchResults('{{ submission_id }}')"
@click.outside="clearResults()">
{% if manufacturers %}
{% for manufacturer in manufacturers %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectManufacturerForSubmission('{{ manufacturer.id }}', '{{ manufacturer.name|escapejs }}', '{{ submission_id }}')">
@click="selectManufacturer('{{ manufacturer.id }}', '{{ manufacturer.name|escapejs }}')">
{{ manufacturer.name }}
</button>
{% endfor %}
@@ -19,49 +22,49 @@
</div>
<script>
function selectManufacturerForSubmission(id, name, submissionId) {
// Debug logging
console.log('Selecting manufacturer:', {id, name, submissionId});
// Find elements
const manufacturerInput = document.querySelector(`#manufacturer-input-${submissionId}`);
const searchInput = document.querySelector(`#manufacturer-search-${submissionId}`);
const resultsDiv = document.querySelector(`#manufacturer-search-results-${submissionId}`);
// Debug logging
console.log('Found elements:', {
manufacturerInput: manufacturerInput?.id,
searchInput: searchInput?.id,
resultsDiv: resultsDiv?.id
});
// Update hidden input
if (manufacturerInput) {
manufacturerInput.value = id;
console.log('Updated manufacturer input value:', manufacturerInput.value);
}
// Update search input
if (searchInput) {
searchInput.value = name;
console.log('Updated search input value:', searchInput.value);
}
// Clear results
if (resultsDiv) {
resultsDiv.innerHTML = '';
console.log('Cleared results div');
}
}
document.addEventListener('alpine:init', () => {
Alpine.data('manufacturerSearchResults', (submissionId) => ({
submissionId: submissionId,
// Close search results when clicking outside
document.addEventListener('click', function(e) {
const searchResults = document.querySelectorAll('[id^="manufacturer-search-results-"]');
searchResults.forEach(function(resultsDiv) {
const searchInput = document.querySelector(`#manufacturer-search-${resultsDiv.id.split('-').pop()}`);
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
resultsDiv.innerHTML = '';
selectManufacturer(id, name) {
// Debug logging
console.log('Selecting manufacturer:', {id, name, submissionId: this.submissionId});
// Find elements using AlpineJS approach
const manufacturerInput = document.querySelector(`#manufacturer-input-${this.submissionId}`);
const searchInput = document.querySelector(`#manufacturer-search-${this.submissionId}`);
const resultsDiv = document.querySelector(`#manufacturer-search-results-${this.submissionId}`);
// Debug logging
console.log('Found elements:', {
manufacturerInput: manufacturerInput?.id,
searchInput: searchInput?.id,
resultsDiv: resultsDiv?.id
});
// Update hidden input
if (manufacturerInput) {
manufacturerInput.value = id;
console.log('Updated manufacturer input value:', manufacturerInput.value);
}
// Update search input
if (searchInput) {
searchInput.value = name;
console.log('Updated search input value:', searchInput.value);
}
// Clear results
this.clearResults();
},
clearResults() {
const resultsDiv = document.querySelector(`#manufacturer-search-results-${this.submissionId}`);
if (resultsDiv) {
resultsDiv.innerHTML = '';
console.log('Cleared results div');
}
}
});
}));
});
</script>

View File

@@ -1,9 +1,12 @@
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600"
style="max-height: 240px; overflow-y: auto;"
x-data="parkSearchResults('{{ submission_id }}')"
@click.outside="clearResults()">
{% if parks %}
{% for park in parks %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectParkForSubmission('{{ park.id }}', '{{ park.name|escapejs }}', '{{ submission_id }}')">
@click="selectPark('{{ park.id }}', '{{ park.name|escapejs }}')">
{{ park.name }}
</button>
{% endfor %}
@@ -19,55 +22,55 @@
</div>
<script>
function selectParkForSubmission(id, name, submissionId) {
// Debug logging
console.log('Selecting park:', {id, name, submissionId});
// Find elements
const parkInput = document.querySelector(`#park-input-${submissionId}`);
const searchInput = document.querySelector(`#park-search-${submissionId}`);
const resultsDiv = document.querySelector(`#park-search-results-${submissionId}`);
// Debug logging
console.log('Found elements:', {
parkInput: parkInput?.id,
searchInput: searchInput?.id,
resultsDiv: resultsDiv?.id
});
// Update hidden input
if (parkInput) {
parkInput.value = id;
console.log('Updated park input value:', parkInput.value);
}
// Update search input
if (searchInput) {
searchInput.value = name;
console.log('Updated search input value:', searchInput.value);
}
// Clear results
if (resultsDiv) {
resultsDiv.innerHTML = '';
console.log('Cleared results div');
}
// Trigger park areas update
if (parkInput) {
htmx.trigger(parkInput, 'change');
console.log('Triggered change event');
}
}
document.addEventListener('alpine:init', () => {
Alpine.data('parkSearchResults', (submissionId) => ({
submissionId: submissionId,
// Close search results when clicking outside
document.addEventListener('click', function(e) {
const searchResults = document.querySelectorAll('[id^="park-search-results-"]');
searchResults.forEach(function(resultsDiv) {
const searchInput = document.querySelector(`#park-search-${resultsDiv.id.split('-').pop()}`);
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
resultsDiv.innerHTML = '';
selectPark(id, name) {
// Debug logging
console.log('Selecting park:', {id, name, submissionId: this.submissionId});
// Find elements using AlpineJS approach
const parkInput = document.querySelector(`#park-input-${this.submissionId}`);
const searchInput = document.querySelector(`#park-search-${this.submissionId}`);
const resultsDiv = document.querySelector(`#park-search-results-${this.submissionId}`);
// Debug logging
console.log('Found elements:', {
parkInput: parkInput?.id,
searchInput: searchInput?.id,
resultsDiv: resultsDiv?.id
});
// Update hidden input
if (parkInput) {
parkInput.value = id;
console.log('Updated park input value:', parkInput.value);
}
// Update search input
if (searchInput) {
searchInput.value = name;
console.log('Updated search input value:', searchInput.value);
}
// Clear results
this.clearResults();
// Trigger park areas update
if (parkInput) {
htmx.trigger(parkInput, 'change');
console.log('Triggered change event');
}
},
clearResults() {
const resultsDiv = document.querySelector(`#park-search-results-${this.submissionId}`);
if (resultsDiv) {
resultsDiv.innerHTML = '';
console.log('Cleared results div');
}
}
});
}));
});
</script>

View File

@@ -1,9 +1,12 @@
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600"
style="max-height: 240px; overflow-y: auto;"
x-data="rideModelSearchResults('{{ submission_id }}')"
@click.outside="clearResults()">
{% if ride_models %}
{% for model in ride_models %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectRideModelForSubmission('{{ model.id }}', '{{ model.name|escapejs }}', '{{ submission_id }}')">
@click="selectRideModel('{{ model.id }}', '{{ model.name|escapejs }}')">
{{ model.name }}
</button>
{% endfor %}
@@ -19,49 +22,49 @@
</div>
<script>
function selectRideModelForSubmission(id, name, submissionId) {
// Debug logging
console.log('Selecting ride model:', {id, name, submissionId});
// Find elements
const modelInput = document.querySelector(`#ride-model-input-${submissionId}`);
const searchInput = document.querySelector(`#ride-model-search-${submissionId}`);
const resultsDiv = document.querySelector(`#ride-model-search-results-${submissionId}`);
// Debug logging
console.log('Found elements:', {
modelInput: modelInput?.id,
searchInput: searchInput?.id,
resultsDiv: resultsDiv?.id
});
// Update hidden input
if (modelInput) {
modelInput.value = id;
console.log('Updated ride model input value:', modelInput.value);
}
// Update search input
if (searchInput) {
searchInput.value = name;
console.log('Updated search input value:', searchInput.value);
}
// Clear results
if (resultsDiv) {
resultsDiv.innerHTML = '';
console.log('Cleared results div');
}
}
document.addEventListener('alpine:init', () => {
Alpine.data('rideModelSearchResults', (submissionId) => ({
submissionId: submissionId,
// Close search results when clicking outside
document.addEventListener('click', function(e) {
const searchResults = document.querySelectorAll('[id^="ride-model-search-results-"]');
searchResults.forEach(function(resultsDiv) {
const searchInput = document.querySelector(`#ride-model-search-${resultsDiv.id.split('-').pop()}`);
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
resultsDiv.innerHTML = '';
selectRideModel(id, name) {
// Debug logging
console.log('Selecting ride model:', {id, name, submissionId: this.submissionId});
// Find elements using AlpineJS approach
const modelInput = document.querySelector(`#ride-model-input-${this.submissionId}`);
const searchInput = document.querySelector(`#ride-model-search-${this.submissionId}`);
const resultsDiv = document.querySelector(`#ride-model-search-results-${this.submissionId}`);
// Debug logging
console.log('Found elements:', {
modelInput: modelInput?.id,
searchInput: searchInput?.id,
resultsDiv: resultsDiv?.id
});
// Update hidden input
if (modelInput) {
modelInput.value = id;
console.log('Updated ride model input value:', modelInput.value);
}
// Update search input
if (searchInput) {
searchInput.value = name;
console.log('Updated search input value:', searchInput.value);
}
// Clear results
this.clearResults();
},
clearResults() {
const resultsDiv = document.querySelector(`#ride-model-search-results-${this.submissionId}`);
if (resultsDiv) {
resultsDiv.innerHTML = '';
console.log('Cleared results div');
}
}
});
}));
});
</script>

View File

@@ -1,5 +1,6 @@
{% load static %}
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
<style>
/* Ensure map container and its elements stay below other UI elements */
.leaflet-pane,
@@ -19,38 +20,132 @@
}
</style>
<div class="location-widget" id="locationWidget">
<div class="location-widget" id="locationWidget"
x-data="{
searchResults: [],
showResults: false,
searchTimeout: null,
init() {
// Initialize map via HTMX
this.initializeMap();
},
initializeMap() {
// Use HTMX to load map component
htmx.ajax('GET', '/maps/location-widget/', {
target: '#locationMap',
swap: 'innerHTML'
});
},
handleSearchInput(query) {
clearTimeout(this.searchTimeout);
if (!query.trim()) {
this.showResults = false;
return;
}
this.searchTimeout = setTimeout(() => {
this.searchLocation(query.trim());
}, 300);
},
searchLocation(query) {
// Use HTMX for location search
htmx.ajax('GET', '/parks/search/location/', {
values: { q: query },
target: '#search-results-container',
swap: 'innerHTML'
});
},
selectLocation(lat, lng, displayName, address) {
// Update coordinates
this.$refs.latitude.value = lat;
this.$refs.longitude.value = lng;
// Update address fields
if (address) {
this.$refs.streetAddress.value = address.street || '';
this.$refs.city.value = address.city || '';
this.$refs.state.value = address.state || '';
this.$refs.country.value = address.country || '';
this.$refs.postalCode.value = address.postal_code || '';
}
// Update search input
this.$refs.searchInput.value = displayName;
this.showResults = false;
// Update map via HTMX
htmx.ajax('POST', '/maps/update-marker/', {
values: { lat: lat, lng: lng },
target: '#locationMap',
swap: 'none'
});
},
handleMapClick(lat, lng) {
// Use HTMX for reverse geocoding
htmx.ajax('GET', '/parks/search/reverse-geocode/', {
values: { lat: lat, lon: lng },
target: '#location-form-fields',
swap: 'none'
});
}
}"
@click.outside="showResults = false">
{# Search Form #}
<div class="relative mb-4">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Search Location
</label>
<input type="text"
id="locationSearch"
x-ref="searchInput"
@input="handleSearchInput($event.target.value)"
hx-get="/parks/search/location/"
hx-trigger="input changed delay:300ms"
hx-target="#search-results-container"
hx-swap="innerHTML"
class="relative w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search for a location..."
autocomplete="off"
style="z-index: 10;">
<div id="searchResults"
<div id="search-results-container"
x-show="showResults"
x-transition
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
class="w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
<!-- Search results will be populated here via HTMX -->
</div>
</div>
{# Map Container #}
<div class="relative mb-4" style="z-index: 1;">
<div id="locationMap" class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
<div id="locationMap" class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600">
<!-- Map will be loaded via HTMX -->
<div class="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-800 rounded-lg">
<div class="text-center">
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
</div>
</div>
</div>
</div>
{# Location Form Fields #}
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2" style="z-index: 10;">
<div id="location-form-fields" class="relative grid grid-cols-1 gap-4 md:grid-cols-2" style="z-index: 10;">
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Street Address
</label>
<input type="text"
name="street_address"
id="streetAddress"
x-ref="streetAddress"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.street_address.value|default:'' }}">
</div>
@@ -60,7 +155,7 @@
</label>
<input type="text"
name="city"
id="city"
x-ref="city"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.city.value|default:'' }}">
</div>
@@ -70,7 +165,7 @@
</label>
<input type="text"
name="state"
id="state"
x-ref="state"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.state.value|default:'' }}">
</div>
@@ -80,7 +175,7 @@
</label>
<input type="text"
name="country"
id="country"
x-ref="country"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.country.value|default:'' }}">
</div>
@@ -90,7 +185,7 @@
</label>
<input type="text"
name="postal_code"
id="postalCode"
x-ref="postalCode"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.postal_code.value|default:'' }}">
</div>
@@ -98,306 +193,19 @@
{# Hidden Coordinate Fields #}
<div class="hidden">
<input type="hidden" name="latitude" id="latitude" value="{{ form.latitude.value|default:'' }}">
<input type="hidden" name="longitude" id="longitude" value="{{ form.longitude.value|default:'' }}">
<input type="hidden" name="latitude" x-ref="latitude" value="{{ form.latitude.value|default:'' }}">
<input type="hidden" name="longitude" x-ref="longitude" value="{{ form.longitude.value|default:'' }}">
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
let map = null;
let marker = null;
const searchInput = document.getElementById('locationSearch');
const searchResults = document.getElementById('searchResults');
let searchTimeout;
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
try {
// Convert to string-3 with exact decimal places
const rounded = Number(value).toFixed(decimalPlaces);
// Convert to string-3 without decimal point for digit counting
const strValue = rounded.replace('.', '').replace('-', '');
// Remove trailing zeros
const strippedValue = strValue.replace(/0+$/, '');
// If total digits exceed maxDigits, round further
if (strippedValue.length > maxDigits) {
return Number(Number(value).toFixed(decimalPlaces - 1));
}
// Return the string-3 representation to preserve exact decimal places
return rounded;
} catch (error) {
console.error('Coordinate normalization failed:', error);
return null;
}
}
function validateCoordinates(lat, lng) {
// Normalize coordinates
const normalizedLat = normalizeCoordinate(lat, 9, 6);
const normalizedLng = normalizeCoordinate(lng, 10, 6);
if (normalizedLat === null || normalizedLng === null) {
throw new Error('Invalid coordinate format');
}
const parsedLat = parseFloat(normalizedLat);
const parsedLng = parseFloat(normalizedLng);
if (parsedLat < -90 || parsedLat > 90) {
throw new Error('Latitude must be between -90 and 90 degrees.');
}
if (parsedLng < -180 || parsedLng > 180) {
throw new Error('Longitude must be between -180 and 180 degrees.');
}
return { lat: normalizedLat, lng: normalizedLng };
}
// Initialize map
function initMap() {
map = L.map('locationMap').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Initialize with existing coordinates if available
const initialLat = document.getElementById('latitude').value;
const initialLng = document.getElementById('longitude').value;
if (initialLat && initialLng) {
try {
const normalized = validateCoordinates(initialLat, initialLng);
addMarker(normalized.lat, normalized.lng);
} catch (error) {
console.error('Invalid initial coordinates:', error);
}
}
// Handle map clicks - HTMX version
map.on('click', function(e) {
try {
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
// Create a temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.style.display = 'none';
tempForm.setAttribute('hx-get', '/parks/search/reverse-geocode/');
tempForm.setAttribute('hx-vals', JSON.stringify({
lat: normalized.lat,
lon: normalized.lng
}));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add event listener for HTMX response
tempForm.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.error) {
throw new Error(data.error);
}
updateLocation(normalized.lat, normalized.lng, data);
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
} else {
console.error('Geocoding request failed');
alert('Failed to update location. Please try again.');
}
// Clean up temporary form
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
<div x-data="{
init() {
// Only essential HTMX error handling as shown in Context7 docs
this.$el.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
console.error('HTMX Error:', evt.detail.xhr.status);
}
});
}
// Initialize map
initMap();
// Handle location search - HTMX version
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (!query) {
searchResults.classList.add('hidden');
return;
}
searchTimeout = setTimeout(function() {
// Create a temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.style.display = 'none';
tempForm.setAttribute('hx-get', '/parks/search/location/');
tempForm.setAttribute('hx-vals', JSON.stringify({
q: query
}));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add event listener for HTMX response
tempForm.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.results && data.results.length > 0) {
const resultsHtml = data.results.map((result, index) => `
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
data-result-index="${index}">
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
${[
result.street,
result.city || (result.address && (result.address.city || result.address.town || result.address.village)),
result.state || (result.address && (result.address.state || result.address.region)),
result.country || (result.address && result.address.country),
result.postal_code || (result.address && result.address.postcode)
].filter(Boolean).join(', ')}
</div>
</div>
`).join('');
searchResults.innerHTML = resultsHtml;
searchResults.classList.remove('hidden');
// Store results data
searchResults.dataset.results = JSON.stringify(data.results);
// Add click handlers
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
el.addEventListener('click', function() {
const results = JSON.parse(searchResults.dataset.results);
const result = results[this.dataset.resultIndex];
selectLocation(result);
});
});
} else {
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
searchResults.classList.remove('hidden');
}
} catch (error) {
console.error('Search failed:', error);
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
searchResults.classList.remove('hidden');
}
} else {
console.error('Search request failed');
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
searchResults.classList.remove('hidden');
}
// Clean up temporary form
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}, 300);
});
// Hide search results when clicking outside
document.addEventListener('click', function(e) {
if (!searchResults.contains(e.target) && e.target !== searchInput) {
searchResults.classList.add('hidden');
}
});
function addMarker(lat, lng) {
if (marker) {
marker.remove();
}
marker = L.marker([lat, lng]).addTo(map);
map.setView([lat, lng], 13);
}
function updateLocation(lat, lng, data) {
try {
const normalized = validateCoordinates(lat, lng);
// Update coordinates
document.getElementById('latitude').value = normalized.lat;
document.getElementById('longitude').value = normalized.lng;
// Update marker
addMarker(normalized.lat, normalized.lng);
// Update form fields with English names where available
const address = data.address || {};
document.getElementById('streetAddress').value =
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
document.getElementById('city').value =
address.city || address.town || address.village || '';
document.getElementById('state').value =
address.state || address.region || '';
document.getElementById('country').value = address.country || '';
document.getElementById('postalCode').value = address.postcode || '';
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
}
function selectLocation(result) {
if (!result) return;
try {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
if (isNaN(lat) || isNaN(lon)) {
throw new Error('Invalid coordinates in search result');
}
const normalized = validateCoordinates(lat, lon);
// Create a normalized address object
const address = {
name: result.display_name || result.name || '',
address: {
house_number: result.house_number || (result.address && result.address.house_number) || '',
road: result.street || (result.address && (result.address.road || result.address.street)) || '',
city: result.city || (result.address && (result.address.city || result.address.town || result.address.village)) || '',
state: result.state || (result.address && (result.address.state || result.address.region)) || '',
country: result.country || (result.address && result.address.country) || '',
postcode: result.postal_code || (result.address && result.address.postcode) || ''
}
};
updateLocation(normalized.lat, normalized.lng, address);
searchResults.classList.add('hidden');
searchInput.value = address.name;
} catch (error) {
console.error('Location selection failed:', error);
alert(error.message || 'Failed to select location. Please try again.');
}
}
// Add form submit handler
const form = document.querySelector('form');
form.addEventListener('submit', function(e) {
const lat = document.getElementById('latitude').value;
const lng = document.getElementById('longitude').value;
if (lat && lng) {
try {
validateCoordinates(lat, lng);
} catch (error) {
e.preventDefault();
alert(error.message || 'Invalid coordinates. Please check the location.');
}
}
});
});
</script>
}"></div>

View File

@@ -1,9 +1,32 @@
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('parkSearchResults', () => ({
selectPark(id, name) {
// Update park fields using AlpineJS reactive approach
const parkInput = this.$el.closest('form').querySelector('#id_park');
const searchInput = this.$el.closest('form').querySelector('#id_park_search');
const resultsDiv = this.$el.closest('form').querySelector('#park-search-results');
if (parkInput) parkInput.value = id;
if (searchInput) searchInput.value = name;
if (resultsDiv) resultsDiv.innerHTML = '';
// Dispatch custom event for parent component
this.$dispatch('park-selected', { id, name });
}
}));
});
</script>
<div x-data="parkSearchResults()"
@click.outside="$el.innerHTML = ''"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600"
style="max-height: 240px; overflow-y: auto;">
{% if parks %}
{% for park in parks %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectPark('{{ park.id }}', '{{ park.name|escapejs }}')">
@click="selectPark('{{ park.id }}', '{{ park.name|escapejs }}')">
{{ park.name }}
</button>
{% endfor %}
@@ -17,11 +40,3 @@
</div>
{% endif %}
</div>
<script>
function selectPark(id, name) {
document.getElementById('id_park').value = id;
document.getElementById('id_park_search').value = name;
document.getElementById('park-search-results').innerHTML = '';
}
</script>

View File

@@ -124,7 +124,81 @@
{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
<div x-data="{
tripParks: [],
showAllParks: false,
mapInitialized: false,
init() {
// Initialize map via HTMX
this.initializeMap();
},
initializeMap() {
// Use HTMX to load map component
htmx.ajax('GET', '/maps/roadtrip-map/', {
target: '#map-container',
swap: 'innerHTML'
});
this.mapInitialized = true;
},
addParkToTrip(parkId, parkName, parkLocation) {
// Check if park already exists
if (!this.tripParks.find(p => p.id === parkId)) {
this.tripParks.push({
id: parkId,
name: parkName,
location: parkLocation
});
}
},
removeParkFromTrip(parkId) {
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
},
clearTrip() {
this.tripParks = [];
},
optimizeRoute() {
if (this.tripParks.length >= 2) {
// Use HTMX to optimize route
htmx.ajax('POST', '/trips/optimize/', {
values: { parks: this.tripParks.map(p => p.id) },
target: '#trip-summary',
swap: 'innerHTML'
});
}
},
calculateRoute() {
if (this.tripParks.length >= 2) {
// Use HTMX to calculate route
htmx.ajax('POST', '/trips/calculate/', {
values: { parks: this.tripParks.map(p => p.id) },
target: '#trip-summary',
swap: 'innerHTML'
});
}
},
saveTrip() {
if (this.tripParks.length > 0) {
// Use HTMX to save trip
htmx.ajax('POST', '/trips/save/', {
values: {
name: 'Trip ' + new Date().toLocaleDateString(),
parks: this.tripParks.map(p => p.id)
},
target: '#saved-trips',
swap: 'innerHTML'
});
}
}
}" class="container px-4 mx-auto">
<!-- Header -->
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
<div>
@@ -167,7 +241,7 @@
</div>
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
<!-- Search results will be populated here -->
<!-- Search results will be populated here via HTMX -->
</div>
</div>
@@ -175,61 +249,80 @@
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
<button id="clear-trip"
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
<button class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
@click="clearTrip()">
<i class="mr-1 fas fa-trash"></i>Clear All
</button>
</div>
<div id="trip-parks" class="space-y-2 min-h-20">
<div id="empty-trip" class="text-center py-8 text-gray-500 dark:text-gray-400">
<i class="fas fa-route text-3xl mb-3"></i>
<p>Add parks to start planning your trip</p>
<p class="text-sm mt-1">Search above or click parks on the map</p>
</div>
<template x-if="tripParks.length === 0">
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<i class="fas fa-route text-3xl mb-3"></i>
<p>Add parks to start planning your trip</p>
<p class="text-sm mt-1">Search above or click parks on the map</p>
</div>
</template>
<template x-for="(park, index) in tripParks" :key="park.id">
<div class="park-card">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-bold mr-3"
x-text="index + 1"></div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white" x-text="park.name"></h4>
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="park.location"></p>
</div>
</div>
<button @click="removeParkFromTrip(park.id)"
class="text-red-500 hover:text-red-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</template>
</div>
<div class="mt-4 space-y-2">
<button id="optimize-route"
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="optimizeRoute()" :disabled="tripParks.length < 2">
<button class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="optimizeRoute()"
:disabled="tripParks.length < 2">
<i class="mr-2 fas fa-route"></i>Optimize Route
</button>
<button id="calculate-route"
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="calculateRoute()" :disabled="tripParks.length < 2">
<button class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="calculateRoute()"
:disabled="tripParks.length < 2">
<i class="mr-2 fas fa-map"></i>Calculate Route
</button>
</div>
</div>
<!-- Trip Summary -->
<div id="trip-summary" class="trip-summary-card hidden">
<div id="trip-summary" class="trip-summary-card" x-show="tripParks.length >= 2" x-transition>
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
<div class="trip-stats">
<div class="trip-stat">
<div class="trip-stat-value" id="total-distance">-</div>
<div class="trip-stat-value">-</div>
<div class="trip-stat-label">Total Miles</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-time">-</div>
<div class="trip-stat-value">-</div>
<div class="trip-stat-label">Drive Time</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-parks">-</div>
<div class="trip-stat-value" x-text="tripParks.length">-</div>
<div class="trip-stat-label">Parks</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-rides">-</div>
<div class="trip-stat-value">-</div>
<div class="trip-stat-label">Total Rides</div>
</div>
</div>
<div class="mt-4">
<button id="save-trip"
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
<button class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
@click="saveTrip()">
<i class="mr-2 fas fa-save"></i>Save Trip
</button>
@@ -243,26 +336,32 @@
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
<div class="flex gap-2">
<button id="fit-route"
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="fitRoute()">
<button class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
hx-post="/maps/fit-route/"
hx-vals='{"parks": "{{ tripParks|join:"," }}"}'
hx-target="#map-container"
hx-swap="none">
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
</button>
<button id="toggle-parks"
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="toggleAllParks()">
<i class="mr-1 fas fa-eye"></i><span x-text="showAllParks ? 'Hide Parks' : 'Show All Parks'">Show All Parks</span>
<button class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="showAllParks = !showAllParks"
hx-post="/maps/toggle-parks/"
hx-vals='{"show": "{{ showAllParks }}"}'
hx-target="#map-container"
hx-swap="none">
<i class="mr-1 fas fa-eye"></i>
<span x-text="showAllParks ? 'Hide Parks' : 'Show All Parks'">Show All Parks</span>
</button>
</div>
</div>
<div id="map-container" class="map-container"></div>
<!-- Map Loading Indicator -->
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
<div class="text-center">
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
<div id="map-container" class="map-container">
<!-- Map will be loaded via HTMX -->
<div class="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-800 rounded-lg">
<div class="text-center">
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
</div>
</div>
</div>
</div>
@@ -286,7 +385,7 @@
hx-get="{% url 'parks:htmx_saved_trips' %}"
hx-trigger="load"
hx-indicator="#trips-loading">
<!-- Saved trips will be loaded here -->
<!-- Saved trips will be loaded here via HTMX -->
</div>
<div id="trips-loading" class="htmx-indicator text-center py-4">
@@ -299,255 +398,19 @@
{% endblock %}
{% block extra_js %}
<!-- Leaflet JS -->
<!-- External libraries for map functionality only -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Leaflet Routing Machine JS -->
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
<!-- Sortable JS for drag & drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('tripPlanner', () => ({
map: null,
tripParks: [],
allParks: [],
showAllParks: false,
routeControl: null,
parkMarkers: {},
init() {
this.initMap();
this.setupSortable();
},
initMap() {
if (typeof L !== 'undefined') {
this.map = L.map('map-container').setView([39.8283, -98.5795], 4);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(this.map);
this.loadAllParks();
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
<div x-data="{
init() {
// Only essential HTMX error handling as shown in Context7 docs
this.$el.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
console.error('HTMX Error:', evt.detail.xhr.status);
}
},
setupSortable() {
if (typeof Sortable !== 'undefined') {
const tripParksEl = document.getElementById('trip-parks');
if (tripParksEl) {
Sortable.create(tripParksEl, {
animation: 150,
ghostClass: 'drag-over',
onEnd: (evt) => {
this.reorderTrip(evt.oldIndex, evt.newIndex);
}
});
}
}
},
loadAllParks() {
// Load parks via HTMX or fetch
fetch('/api/parks/')
.then(response => response.json())
.then(data => {
this.allParks = data;
this.displayAllParks();
})
.catch(error => console.error('Error loading parks:', error));
},
displayAllParks() {
this.allParks.forEach(park => {
if (park.latitude && park.longitude) {
const marker = L.marker([park.latitude, park.longitude])
.bindPopup(`
<div class="p-2">
<h3 class="font-semibold">${park.name}</h3>
<p class="text-sm text-gray-600">${park.location || ''}</p>
<button onclick="Alpine.store('tripPlanner').addParkToTrip(${park.id})"
class="mt-2 px-2 py-1 bg-blue-500 text-white text-xs rounded">
Add to Trip
</button>
</div>
`);
this.parkMarkers[park.id] = marker;
if (this.showAllParks) {
marker.addTo(this.map);
}
}
});
},
addParkToTrip(parkId) {
const park = this.allParks.find(p => p.id === parkId);
if (park && !this.tripParks.find(p => p.id === parkId)) {
this.tripParks.push(park);
this.updateTripDisplay();
this.updateButtons();
}
},
removeParkFromTrip(parkId) {
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
this.updateTripDisplay();
this.updateButtons();
this.clearRoute();
},
clearTrip() {
this.tripParks = [];
this.updateTripDisplay();
this.updateButtons();
this.clearRoute();
},
updateTripDisplay() {
const container = document.getElementById('trip-parks');
const emptyState = document.getElementById('empty-trip');
if (this.tripParks.length === 0) {
emptyState.style.display = 'block';
container.innerHTML = emptyState.outerHTML;
} else {
container.innerHTML = this.tripParks.map((park, index) => `
<div class="park-card draggable-item" data-park-id="${park.id}">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-bold mr-3">
${index + 1}
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">${park.name}</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">${park.location || ''}</p>
</div>
</div>
<button @click="removeParkFromTrip(${park.id})"
class="text-red-500 hover:text-red-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
`).join('');
}
},
updateButtons() {
const hasParks = this.tripParks.length >= 2;
document.getElementById('optimize-route').disabled = !hasParks;
document.getElementById('calculate-route').disabled = !hasParks;
},
optimizeRoute() {
// Implement route optimization logic
console.log('Optimizing route for', this.tripParks.length, 'parks');
},
calculateRoute() {
if (this.tripParks.length < 2) return;
this.clearRoute();
const waypoints = this.tripParks.map(park =>
L.latLng(park.latitude, park.longitude)
);
if (typeof L.Routing !== 'undefined') {
this.routeControl = L.Routing.control({
waypoints: waypoints,
routeWhileDragging: false,
addWaypoints: false,
createMarker: (i, waypoint, n) => {
const park = this.tripParks[i];
return L.marker(waypoint.latLng, {
icon: L.divIcon({
html: `<div class="waypoint-marker-inner">${i + 1}</div>`,
className: `waypoint-marker ${i === 0 ? 'waypoint-start' : i === n - 1 ? 'waypoint-end' : 'waypoint-stop'}`,
iconSize: [30, 30]
})
}).bindPopup(park.name);
}
}).addTo(this.map);
this.routeControl.on('routesfound', (e) => {
const route = e.routes[0];
this.updateTripSummary(route);
});
}
},
clearRoute() {
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
this.hideTripSummary();
},
updateTripSummary(route) {
const summary = route.summary;
document.getElementById('total-distance').textContent = Math.round(summary.totalDistance / 1609.34); // Convert to miles
document.getElementById('total-time').textContent = Math.round(summary.totalTime / 3600) + 'h';
document.getElementById('total-parks').textContent = this.tripParks.length;
document.getElementById('total-rides').textContent = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
document.getElementById('trip-summary').classList.remove('hidden');
},
hideTripSummary() {
document.getElementById('trip-summary').classList.add('hidden');
},
fitRoute() {
if (this.tripParks.length > 0) {
const group = new L.featureGroup(
this.tripParks.map(park => L.marker([park.latitude, park.longitude]))
);
this.map.fitBounds(group.getBounds().pad(0.1));
}
},
toggleAllParks() {
this.showAllParks = !this.showAllParks;
Object.values(this.parkMarkers).forEach(marker => {
if (this.showAllParks) {
marker.addTo(this.map);
} else {
this.map.removeLayer(marker);
}
});
},
reorderTrip(oldIndex, newIndex) {
const park = this.tripParks.splice(oldIndex, 1)[0];
this.tripParks.splice(newIndex, 0, park);
this.updateTripDisplay();
this.clearRoute();
},
saveTrip() {
if (this.tripParks.length === 0) return;
const tripData = {
name: `Trip ${new Date().toLocaleDateString()}`,
parks: this.tripParks.map(p => p.id)
};
// Save via HTMX
htmx.ajax('POST', '/trips/save/', {
values: tripData,
target: '#saved-trips',
swap: 'innerHTML'
});
}
}));
});
</script>
<!-- Trip Planner Component Container -->
<div x-data="tripPlanner" x-init="init()" style="display: none;"></div>
});
}
}"></div>
{% endblock %}

View File

@@ -173,22 +173,39 @@
{% endif %}
<!-- Search Suggestions -->
<div class="flex flex-wrap gap-2 justify-center">
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('searchSuggestions', () => ({
fillSearchInput(value) {
// Find the search input using AlpineJS approach
const searchInput = document.querySelector('input[type=text]');
if (searchInput) {
searchInput.value = value;
// Dispatch input event to trigger search
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
searchInput.focus();
}
}
}));
});
</script>
<div x-data="searchSuggestions()" class="flex flex-wrap gap-2 justify-center">
<span class="text-xs text-muted-foreground">Try:</span>
<button class="text-xs text-primary hover:text-primary/80 transition-colors"
onclick="document.querySelector('input[type=text]').value='Disney'; document.querySelector('input[type=text]').dispatchEvent(new Event('input'));">
@click="fillSearchInput('Disney')">
Disney
</button>
<span class="text-xs text-muted-foreground"></span>
<button class="text-xs text-primary hover:text-primary/80 transition-colors"
onclick="document.querySelector('input[type=text]').value='roller coaster'; document.querySelector('input[type=text]').dispatchEvent(new Event('input'));">
@click="fillSearchInput('roller coaster')">
Roller Coaster
</button>
<span class="text-xs text-muted-foreground"></span>
<button class="text-xs text-primary hover:text-primary/80 transition-colors"
onclick="document.querySelector('input[type=text]').value='Cedar Point'; document.querySelector('input[type=text]').dispatchEvent(new Event('input'));">
@click="fillSearchInput('Cedar Point')">
Cedar Point
</button>
</div>
</div>
{% endif %}
{% endif %}

View File

@@ -1,47 +1,79 @@
<!-- Add Ride Modal -->
<div id="add-ride-modal" class="fixed inset-0 z-50 hidden overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen p-4">
<!-- Background overlay -->
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true"></div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('addRideModal', () => ({
isOpen: false,
openModal() {
this.isOpen = true;
},
closeModal() {
this.isOpen = false;
},
handleBackdropClick(event) {
if (event.target === event.currentTarget) {
this.closeModal();
}
}
}));
});
</script>
<!-- Modal panel -->
<div class="relative w-full max-w-3xl p-6 mx-auto bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Add Ride at {{ park.name }}
</h2>
</div>
<div x-data="addRideModal()">
<!-- Add Ride Modal -->
<div x-show="isOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="handleBackdropClick($event)"
@keydown.escape.window="closeModal()"
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
style="display: none;">
<div class="flex items-center justify-center min-h-screen p-4">
<!-- Background overlay -->
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true"></div>
<div id="modal-content">
{% include "rides/partials/ride_form.html" with modal=True %}
<!-- Modal panel -->
<div class="relative w-full max-w-3xl p-6 mx-auto bg-white rounded-lg shadow-xl dark:bg-gray-800"
x-transition:enter="transition ease-out duration-300 transform"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-200 transform"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Add Ride at {{ park.name }}
</h2>
<button @click="closeModal()"
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" 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>
</div>
<div id="modal-content">
{% include "rides/partials/ride_form.html" with modal=True %}
</div>
</div>
</div>
</div>
<!-- Modal Toggle Button -->
<button type="button"
@click="openModal()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Ride
</button>
</div>
<!-- Modal Toggle Button -->
<button type="button"
onclick="openModal('add-ride-modal')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Ride
</button>
<script>
function openModal(modalId) {
document.getElementById(modalId).classList.remove('hidden');
}
function closeModal() {
document.getElementById('add-ride-modal').classList.add('hidden');
}
// Close modal when clicking outside
document.getElementById('add-ride-modal').addEventListener('click', function(event) {
if (event.target === this) {
closeModal();
}
});
</script>

View File

@@ -1,35 +1,66 @@
{% load static %}
<form method="post"
class="space-y-6"
x-data="{ submitting: false }"
@submit.prevent="
if (!submitting) {
submitting = true;
const formData = new FormData($event.target);
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('designerForm', () => ({
submitting: false,
init() {
// Listen for HTMX events on this form
this.$el.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.pathInfo.requestPath === '/rides/designers/create/') {
this.handleResponse(event);
}
});
},
async submitForm(event) {
if (this.submitting) return;
this.submitting = true;
const formData = new FormData(event.target);
const csrfToken = this.$el.querySelector('[name=csrfmiddlewaretoken]').value;
// Use HTMX for form submission
htmx.ajax('POST', '/rides/designers/create/', {
values: Object.fromEntries(formData),
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
'X-CSRFToken': csrfToken
},
target: this.$el,
swap: 'none'
});
},
handleResponse(event) {
this.submitting = false;
// Handle HTMX response using event listeners
document.addEventListener('htmx:afterRequest', function handleResponse(event) {
if (event.detail.pathInfo.requestPath === '/rides/designers/create/') {
document.removeEventListener('htmx:afterRequest', handleResponse);
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
const data = JSON.parse(event.detail.xhr.response);
if (typeof selectDesigner === 'function') {
selectDesigner(data.id, data.name);
}
$dispatch('close-designer-modal');
}
submitting = false;
}
});
}">
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
const data = JSON.parse(event.detail.xhr.response);
// Dispatch event with designer data for parent components
this.$dispatch('designer-created', {
id: data.id,
name: data.name
});
// Close modal if in modal context
this.$dispatch('close-designer-modal');
} else {
// Handle error case
this.$dispatch('designer-creation-error', {
error: event.detail.xhr.responseText
});
}
}
}));
});
</script>
<form method="post"
class="space-y-6"
x-data="designerForm()"
@submit.prevent="submitForm($event)">
{% csrf_token %}
<div id="designer-form-notification"></div>

View File

@@ -1,9 +1,32 @@
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('designerSearchResults', () => ({
selectDesigner(id, name) {
// Update designer fields using AlpineJS reactive approach
const designerInput = this.$el.closest('form').querySelector('#id_designer');
const searchInput = this.$el.closest('form').querySelector('#id_designer_search');
const resultsDiv = this.$el.closest('form').querySelector('#designer-search-results');
if (designerInput) designerInput.value = id;
if (searchInput) searchInput.value = name;
if (resultsDiv) resultsDiv.innerHTML = '';
// Dispatch custom event for parent component
this.$dispatch('designer-selected', { id, name });
}
}));
});
</script>
<div x-data="designerSearchResults()"
@click.outside="$el.innerHTML = ''"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600"
style="max-height: 240px; overflow-y: auto;">
{% if designers %}
{% for designer in designers %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectDesigner('{{ designer.id }}', '{{ designer.name|escapejs }}')">
@click="selectDesigner('{{ designer.id }}', '{{ designer.name|escapejs }}')">
{{ designer.name }}
</button>
{% endfor %}
@@ -17,11 +40,3 @@
</div>
{% endif %}
</div>
<script>
function selectDesigner(id, name) {
document.getElementById('id_designer').value = id;
document.getElementById('id_designer_search').value = name;
document.getElementById('designer-search-results').innerHTML = '';
}
</script>

View File

@@ -1,7 +1,45 @@
{% load static %}
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
<!-- Advanced Ride Filters Sidebar -->
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto">
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto"
x-data="{
sections: {
'search-section': true,
'basic-section': true,
'date-section': false,
'height-section': false,
'performance-section': false,
'relationships-section': false,
'coaster-section': false,
'sorting-section': false
},
init() {
// Restore section states from localStorage using AlpineJS patterns
Object.keys(this.sections).forEach(sectionId => {
const state = localStorage.getItem('filter-' + sectionId);
if (state !== null) {
this.sections[sectionId] = state === 'open';
}
});
},
toggleSection(sectionId) {
this.sections[sectionId] = !this.sections[sectionId];
localStorage.setItem('filter-' + sectionId, this.sections[sectionId] ? 'open' : 'closed');
},
removeFilter(category, filterName) {
// Use HTMX to remove filter
htmx.ajax('POST', '/rides/remove-filter/', {
values: { category: category, filter: filterName },
target: '#filter-results',
swap: 'outerHTML'
});
}
}">
<!-- 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">
@@ -42,7 +80,7 @@
{{ filter_name }}: {{ filter_value }}
<button type="button"
class="ml-1 h-3 w-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
onclick="removeFilter('{{ category }}', '{{ filter_name }}')">
@click="removeFilter('{{ category }}', '{{ filter_name }}')">
<i class="fas fa-times text-xs"></i>
</button>
</span>
@@ -67,16 +105,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
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"
data-target="search-section">
@click="toggleSection('search-section')">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-search mr-2 text-gray-500"></i>
Search
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
<i class="fas fa-chevron-down transform transition-transform duration-200"
:class="{ 'rotate-180': sections['search-section'] }"></i>
</div>
</button>
<div id="search-section" class="filter-content p-4 space-y-3">
<div id="search-section" class="filter-content p-4 space-y-3" x-show="sections['search-section']" x-transition>
{{ filter_form.search_text.label_tag }}
{{ filter_form.search_text }}
@@ -93,16 +132,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
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"
data-target="basic-section">
@click="toggleSection('basic-section')">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-info-circle mr-2 text-gray-500"></i>
Basic Info
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
<i class="fas fa-chevron-down transform transition-transform duration-200"
:class="{ 'rotate-180': sections['basic-section'] }"></i>
</div>
</button>
<div id="basic-section" class="filter-content p-4 space-y-4">
<div id="basic-section" class="filter-content p-4 space-y-4" x-show="sections['basic-section']" x-transition>
<!-- Categories -->
<div>
{{ filter_form.categories.label_tag }}
@@ -127,16 +167,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
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"
data-target="date-section">
@click="toggleSection('date-section')">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-calendar mr-2 text-gray-500"></i>
Date Ranges
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
<i class="fas fa-chevron-down transform transition-transform duration-200"
:class="{ 'rotate-180': sections['date-section'] }"></i>
</div>
</button>
<div id="date-section" class="filter-content p-4 space-y-4">
<div id="date-section" class="filter-content p-4 space-y-4" x-show="sections['date-section']" x-transition>
<!-- Opening Date Range -->
<div>
{{ filter_form.opening_date_range.label_tag }}
@@ -155,16 +196,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
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"
data-target="height-section">
@click="toggleSection('height-section')">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-ruler-vertical mr-2 text-gray-500"></i>
Height & Safety
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
<i class="fas fa-chevron-down transform transition-transform duration-200"
:class="{ 'rotate-180': sections['height-section'] }"></i>
</div>
</button>
<div id="height-section" class="filter-content p-4 space-y-4">
<div id="height-section" class="filter-content p-4 space-y-4" x-show="sections['height-section']" x-transition>
<!-- Height Requirements -->
<div>
{{ filter_form.height_requirements.label_tag }}
@@ -189,16 +231,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
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"
data-target="performance-section">
@click="toggleSection('performance-section')">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-tachometer-alt mr-2 text-gray-500"></i>
Performance
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
<i class="fas fa-chevron-down transform transition-transform duration-200"
:class="{ 'rotate-180': sections['performance-section'] }"></i>
</div>
</button>
<div id="performance-section" class="filter-content p-4 space-y-4">
<div id="performance-section" class="filter-content p-4 space-y-4" x-show="sections['performance-section']" x-transition>
<!-- Speed Range -->
<div>
{{ filter_form.speed_range.label_tag }}
@@ -229,16 +272,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
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"
data-target="relationships-section">
@click="toggleSection('relationships-section')">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-sitemap mr-2 text-gray-500"></i>
Companies & Models
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
<i class="fas fa-chevron-down transform transition-transform duration-200"
:class="{ 'rotate-180': sections['relationships-section'] }"></i>
</div>
</button>
<div id="relationships-section" class="filter-content p-4 space-y-4">
<div id="relationships-section" class="filter-content p-4 space-y-4" x-show="sections['relationships-section']" x-transition>
<!-- Manufacturers -->
<div>
{{ filter_form.manufacturers.label_tag }}
@@ -263,16 +307,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
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"
data-target="coaster-section">
@click="toggleSection('coaster-section')">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-mountain mr-2 text-gray-500"></i>
Roller Coaster Details
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
<i class="fas fa-chevron-down transform transition-transform duration-200"
:class="{ 'rotate-180': sections['coaster-section'] }"></i>
</div>
</button>
<div id="coaster-section" class="filter-content p-4 space-y-4">
<div id="coaster-section" class="filter-content p-4 space-y-4" x-show="sections['coaster-section']" x-transition>
<!-- Track Type -->
<div>
{{ filter_form.track_types.label_tag }}
@@ -324,16 +369,17 @@
<div class="filter-section">
<button type="button"
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"
data-target="sorting-section">
@click="toggleSection('sorting-section')">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-sort mr-2 text-gray-500"></i>
Sorting
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
<i class="fas fa-chevron-down transform transition-transform duration-200"
:class="{ 'rotate-180': sections['sorting-section'] }"></i>
</div>
</button>
<div id="sorting-section" class="filter-content p-4 space-y-4">
<div id="sorting-section" class="filter-content p-4 space-y-4" x-show="sections['sorting-section']" x-transition>
<!-- Sort By -->
<div>
{{ filter_form.sort_by.label_tag }}
@@ -350,116 +396,14 @@
</form>
</div>
<!-- Filter JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize collapsible sections
initializeFilterSections();
// Initialize filter form handlers
initializeFilterForm();
});
function initializeFilterSections() {
const toggles = document.querySelectorAll('.filter-toggle');
toggles.forEach(toggle => {
toggle.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const content = document.getElementById(targetId);
const chevron = this.querySelector('.fa-chevron-down');
if (content.style.display === 'none' || content.style.display === '') {
content.style.display = 'block';
chevron.style.transform = 'rotate(180deg)';
localStorage.setItem(`filter-${targetId}`, 'open');
} else {
content.style.display = 'none';
chevron.style.transform = 'rotate(0deg)';
localStorage.setItem(`filter-${targetId}`, 'closed');
}
});
// Restore section state from localStorage
const targetId = toggle.getAttribute('data-target');
const content = document.getElementById(targetId);
const chevron = toggle.querySelector('.fa-chevron-down');
const state = localStorage.getItem(`filter-${targetId}`);
if (state === 'closed') {
content.style.display = 'none';
chevron.style.transform = 'rotate(0deg)';
} else {
content.style.display = 'block';
chevron.style.transform = 'rotate(180deg)';
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
<div x-data="{
init() {
// Only essential HTMX error handling as shown in Context7 docs
this.$el.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
console.error('HTMX Error:', evt.detail.xhr.status);
}
});
}
});
}
function initializeFilterForm() {
const form = document.getElementById('filter-form');
if (!form) return;
// Handle multi-select changes
const selects = form.querySelectorAll('select[multiple]');
selects.forEach(select => {
select.addEventListener('change', function() {
// Trigger HTMX update
htmx.trigger(form, 'change');
});
});
// Handle range inputs
const rangeInputs = form.querySelectorAll('input[type="range"], input[type="number"]');
rangeInputs.forEach(input => {
input.addEventListener('input', function() {
// Debounced update
clearTimeout(this.updateTimeout);
this.updateTimeout = setTimeout(() => {
htmx.trigger(form, 'input');
}, 500);
});
});
}
function removeFilter(category, filterName) {
const form = document.getElementById('filter-form');
const input = form.querySelector(`[name*="${filterName}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = false;
} else if (input.tagName === 'SELECT') {
if (input.multiple) {
Array.from(input.options).forEach(option => option.selected = false);
} else {
input.value = '';
}
} else {
input.value = '';
}
// Trigger form update
htmx.trigger(form, 'change');
}
}
// Update filter counts
function updateFilterCounts() {
const form = document.getElementById('filter-form');
const formData = new FormData(form);
let activeCount = 0;
for (let [key, value] of formData.entries()) {
if (value && value.trim() !== '') {
activeCount++;
}
}
const badge = document.querySelector('.filter-count-badge');
if (badge) {
badge.textContent = activeCount;
badge.style.display = activeCount > 0 ? 'inline-flex' : 'none';
}
}
</script>
}"></div>

View File

@@ -1,35 +1,66 @@
{% load static %}
<form method="post"
class="space-y-6"
x-data="{ submitting: false }"
@submit.prevent="
if (!submitting) {
submitting = true;
const formData = new FormData($event.target);
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('manufacturerForm', () => ({
submitting: false,
init() {
// Listen for HTMX events on this form
this.$el.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.pathInfo.requestPath === '/rides/manufacturers/create/') {
this.handleResponse(event);
}
});
},
async submitForm(event) {
if (this.submitting) return;
this.submitting = true;
const formData = new FormData(event.target);
const csrfToken = this.$el.querySelector('[name=csrfmiddlewaretoken]').value;
// Use HTMX for form submission
htmx.ajax('POST', '/rides/manufacturers/create/', {
values: Object.fromEntries(formData),
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
'X-CSRFToken': csrfToken
},
target: this.$el,
swap: 'none'
});
},
handleResponse(event) {
this.submitting = false;
// Handle HTMX response using event listeners
document.addEventListener('htmx:afterRequest', function handleResponse(event) {
if (event.detail.pathInfo.requestPath === '/rides/manufacturers/create/') {
document.removeEventListener('htmx:afterRequest', handleResponse);
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
const data = JSON.parse(event.detail.xhr.response);
if (typeof selectManufacturer === 'function') {
selectManufacturer(data.id, data.name);
}
$dispatch('close-manufacturer-modal');
}
submitting = false;
}
});
}">
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
const data = JSON.parse(event.detail.xhr.response);
// Dispatch event with manufacturer data for parent components
this.$dispatch('manufacturer-created', {
id: data.id,
name: data.name
});
// Close modal if in modal context
this.$dispatch('close-manufacturer-modal');
} else {
// Handle error case
this.$dispatch('manufacturer-creation-error', {
error: event.detail.xhr.responseText
});
}
}
}));
});
</script>
<form method="post"
class="space-y-6"
x-data="manufacturerForm()"
@submit.prevent="submitForm($event)">
{% csrf_token %}
<div id="manufacturer-form-notification"></div>

View File

@@ -1,9 +1,38 @@
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('manufacturerSearchResults', () => ({
selectManufacturer(id, name) {
// Update manufacturer fields using AlpineJS reactive approach
const manufacturerInput = this.$el.closest('form').querySelector('#id_manufacturer');
const searchInput = this.$el.closest('form').querySelector('#id_manufacturer_search');
const resultsDiv = this.$el.closest('form').querySelector('#manufacturer-search-results');
if (manufacturerInput) manufacturerInput.value = id;
if (searchInput) searchInput.value = name;
if (resultsDiv) resultsDiv.innerHTML = '';
// Update ride model search to include manufacturer using HTMX
const rideModelSearch = this.$el.closest('form').querySelector('#id_ride_model_search');
if (rideModelSearch) {
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
}
// Dispatch custom event for parent component
this.$dispatch('manufacturer-selected', { id, name });
}
}));
});
</script>
<div x-data="manufacturerSearchResults()"
@click.outside="$el.innerHTML = ''"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600"
style="max-height: 240px; overflow-y: auto;">
{% if manufacturers %}
{% for manufacturer in manufacturers %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectManufacturer('{{ manufacturer.id }}', '{{ manufacturer.name|escapejs }}')">
@click="selectManufacturer('{{ manufacturer.id }}', '{{ manufacturer.name|escapejs }}')">
{{ manufacturer.name }}
</button>
{% endfor %}
@@ -17,17 +46,3 @@
</div>
{% endif %}
</div>
<script>
function selectManufacturer(id, name) {
document.getElementById('id_manufacturer').value = id;
document.getElementById('id_manufacturer_search').value = name;
document.getElementById('manufacturer-search-results').innerHTML = '';
// Update ride model search to include manufacturer
const rideModelSearch = document.getElementById('id_ride_model_search');
if (rideModelSearch) {
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
}
}
</script>

View File

@@ -1,59 +1,92 @@
{% load static %}
<script>
function selectManufacturer(id, name) {
document.getElementById('id_manufacturer').value = id;
document.getElementById('id_manufacturer_search').value = name;
document.getElementById('manufacturer-search-results').innerHTML = '';
// Update ride model search to include manufacturer
const rideModelSearch = document.getElementById('id_ride_model_search');
if (rideModelSearch) {
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
}
}
document.addEventListener('alpine:init', () => {
Alpine.data('rideForm', () => ({
init() {
// Handle form submission cleanup
this.$el.addEventListener('submit', () => {
this.clearAllSearchResults();
});
},
function selectDesigner(id, name) {
document.getElementById('id_designer').value = id;
document.getElementById('id_designer_search').value = name;
document.getElementById('designer-search-results').innerHTML = '';
}
selectManufacturer(id, name) {
// Use AlpineJS $el to scope queries within component
const manufacturerInput = this.$el.querySelector('#id_manufacturer');
const manufacturerSearch = this.$el.querySelector('#id_manufacturer_search');
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
if (manufacturerInput) manufacturerInput.value = id;
if (manufacturerSearch) manufacturerSearch.value = name;
if (manufacturerResults) manufacturerResults.innerHTML = '';
// Update ride model search to include manufacturer
const rideModelSearch = this.$el.querySelector('#id_ride_model_search');
if (rideModelSearch) {
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
}
},
function selectRideModel(id, name) {
document.getElementById('id_ride_model').value = id;
document.getElementById('id_ride_model_search').value = name;
document.getElementById('ride-model-search-results').innerHTML = '';
}
selectDesigner(id, name) {
// Use AlpineJS $el to scope queries within component
const designerInput = this.$el.querySelector('#id_designer');
const designerSearch = this.$el.querySelector('#id_designer_search');
const designerResults = this.$el.querySelector('#designer-search-results');
if (designerInput) designerInput.value = id;
if (designerSearch) designerSearch.value = name;
if (designerResults) designerResults.innerHTML = '';
},
// Handle form submission
document.addEventListener('submit', function(e) {
if (e.target.id === 'ride-form') {
// Clear search results
document.getElementById('manufacturer-search-results').innerHTML = '';
document.getElementById('designer-search-results').innerHTML = '';
document.getElementById('ride-model-search-results').innerHTML = '';
}
});
selectRideModel(id, name) {
// Use AlpineJS $el to scope queries within component
const rideModelInput = this.$el.querySelector('#id_ride_model');
const rideModelSearch = this.$el.querySelector('#id_ride_model_search');
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
if (rideModelInput) rideModelInput.value = id;
if (rideModelSearch) rideModelSearch.value = name;
if (rideModelResults) rideModelResults.innerHTML = '';
},
// Handle clicks outside search results
document.addEventListener('click', function(e) {
const manufacturerResults = document.getElementById('manufacturer-search-results');
const designerResults = document.getElementById('designer-search-results');
const rideModelResults = document.getElementById('ride-model-search-results');
if (!e.target.closest('#manufacturer-search-container')) {
manufacturerResults.innerHTML = '';
}
if (!e.target.closest('#designer-search-container')) {
designerResults.innerHTML = '';
}
if (!e.target.closest('#ride-model-search-container')) {
rideModelResults.innerHTML = '';
}
clearAllSearchResults() {
// Use AlpineJS $el to scope queries within component
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
const designerResults = this.$el.querySelector('#designer-search-results');
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
if (manufacturerResults) manufacturerResults.innerHTML = '';
if (designerResults) designerResults.innerHTML = '';
if (rideModelResults) rideModelResults.innerHTML = '';
},
clearManufacturerResults() {
// Use AlpineJS $el to scope queries within component
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
if (manufacturerResults) manufacturerResults.innerHTML = '';
},
clearDesignerResults() {
// Use AlpineJS $el to scope queries within component
const designerResults = this.$el.querySelector('#designer-search-results');
if (designerResults) designerResults.innerHTML = '';
},
clearRideModelResults() {
// Use AlpineJS $el to scope queries within component
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
if (rideModelResults) rideModelResults.innerHTML = '';
}
}));
});
</script>
<form method="post" id="ride-form" class="space-y-6" enctype="multipart/form-data">
<form method="post"
id="ride-form"
class="space-y-6"
enctype="multipart/form-data"
x-data="rideForm"
x-init="init()">
{% csrf_token %}
<!-- Park Area -->
@@ -86,7 +119,9 @@ document.addEventListener('click', function(e) {
<!-- Manufacturer -->
<div class="space-y-2">
<div id="manufacturer-search-container" class="relative">
<div id="manufacturer-search-container"
class="relative"
@click.outside="clearManufacturerResults()">
<label for="{{ form.manufacturer_search.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Manufacturer
</label>
@@ -103,7 +138,9 @@ document.addEventListener('click', function(e) {
<!-- Designer -->
<div class="space-y-2">
<div id="designer-search-container" class="relative">
<div id="designer-search-container"
class="relative"
@click.outside="clearDesignerResults()">
<label for="{{ form.designer_search.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Designer
</label>
@@ -120,7 +157,9 @@ document.addEventListener('click', function(e) {
<!-- Ride Model -->
<div class="space-y-2">
<div id="ride-model-search-container" class="relative">
<div id="ride-model-search-container"
class="relative"
@click.outside="clearRideModelResults()">
<label for="{{ form.ride_model_search.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Ride Model
</label>

View File

@@ -1,53 +1,103 @@
{% load static %}
<form method="post"
class="space-y-6"
x-data="{
submitting: false,
manufacturerSearchTerm: '',
setManufacturerModal(value, term = '') {
const parentForm = document.querySelector('[x-data]');
if (parentForm) {
const parentData = Alpine.$data(parentForm);
if (parentData && parentData.setManufacturerModal) {
parentData.setManufacturerModal(value, term);
}
}
}
}"
@submit.prevent="
if (!submitting) {
submitting = true;
const formData = new FormData($event.target);
htmx.ajax('POST', '/rides/models/create/', {
values: Object.fromEntries(formData),
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('rideModelForm', () => ({
submitting: false,
manufacturerSearchTerm: '',
init() {
// Listen for HTMX events on this form
this.$el.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.pathInfo.requestPath === '/rides/models/create/') {
this.handleResponse(event);
}
});
// Handle HTMX response using event listeners
document.addEventListener('htmx:afterRequest', function handleResponse(event) {
if (event.detail.pathInfo.requestPath === '/rides/models/create/') {
document.removeEventListener('htmx:afterRequest', handleResponse);
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
const data = JSON.parse(event.detail.xhr.response);
if (typeof selectRideModel === 'function') {
selectRideModel(data.id, data.name);
}
const parentForm = document.querySelector('[x-data]');
if (parentForm) {
const parentData = Alpine.$data(parentForm);
if (parentData && parentData.setRideModelModal) {
parentData.setRideModelModal(false);
}
}
}
submitting = false;
}
// Initialize form with any pre-filled values
this.initializeForm();
},
initializeForm() {
const searchInput = this.$el.querySelector('#id_ride_model_search');
const nameInput = this.$el.querySelector('#id_name');
if (searchInput && searchInput.value && nameInput) {
nameInput.value = searchInput.value;
}
},
setManufacturerModal(value, term = '') {
// Dispatch event to parent component to handle manufacturer modal
this.$dispatch('set-manufacturer-modal', {
show: value,
searchTerm: term
});
}">
},
async submitForm(event) {
if (this.submitting) return;
this.submitting = true;
const formData = new FormData(event.target);
const csrfToken = this.$el.querySelector('[name=csrfmiddlewaretoken]').value;
// Use HTMX for form submission
htmx.ajax('POST', '/rides/models/create/', {
values: Object.fromEntries(formData),
headers: {
'X-CSRFToken': csrfToken
},
target: this.$el,
swap: 'none'
});
},
handleResponse(event) {
this.submitting = false;
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
const data = JSON.parse(event.detail.xhr.response);
// Dispatch event with ride model data for parent components
this.$dispatch('ride-model-created', {
id: data.id,
name: data.name
});
// Close modal if in modal context
this.$dispatch('close-ride-model-modal');
} else {
// Handle error case
this.$dispatch('ride-model-creation-error', {
error: event.detail.xhr.responseText
});
}
},
selectManufacturer(manufacturerId, manufacturerName) {
// Update manufacturer fields using AlpineJS reactive approach
const manufacturerInput = this.$el.querySelector('#id_manufacturer');
const searchInput = this.$el.querySelector('#id_manufacturer_search');
const resultsDiv = this.$el.querySelector('#manufacturer-search-results');
if (manufacturerInput) manufacturerInput.value = manufacturerId;
if (searchInput) searchInput.value = manufacturerName;
if (resultsDiv) resultsDiv.innerHTML = '';
},
clearManufacturerResults() {
const resultsDiv = this.$el.querySelector('#manufacturer-search-results');
if (resultsDiv) resultsDiv.innerHTML = '';
}
}));
});
</script>
<form method="post"
class="space-y-6"
x-data="rideModelForm()"
@submit.prevent="submitForm($event)"
@click.outside="clearManufacturerResults()">
{% csrf_token %}
<div id="ride-model-notification"></div>
@@ -175,49 +225,3 @@
</button>
</div>
</form>
<script>
function selectManufacturer(manufacturerId, manufacturerName) {
// Update the hidden manufacturer field
document.getElementById('id_manufacturer').value = manufacturerId;
// Update the search input with the manufacturer name
document.getElementById('id_manufacturer_search').value = manufacturerName;
// Clear the search results
document.getElementById('manufacturer-search-results').innerHTML = '';
}
// Close search results when clicking outside
document.addEventListener('click', function(event) {
// Get the parent form element that contains the Alpine.js data
const formElement = event.target.closest('form[x-data]');
if (!formElement) return;
// Get Alpine.js data from the form
const formData = formElement.__x.$data;
// Don't handle clicks if manufacturer modal is open
if (formData.showManufacturerModal) {
return;
}
const searchResults = [
{ input: 'id_manufacturer_search', results: 'manufacturer-search-results' }
];
searchResults.forEach(function(item) {
const input = document.getElementById(item.input);
const results = document.getElementById(item.results);
if (results && !results.contains(event.target) && event.target !== input) {
results.innerHTML = '';
}
});
});
// Initialize form with any pre-filled values
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('id_ride_model_search');
if (searchInput && searchInput.value) {
document.getElementById('id_name').value = searchInput.value;
}
});
</script>

View File

@@ -1,4 +1,27 @@
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('rideModelSearchResults', () => ({
selectRideModel(id, name) {
// Update ride model fields using AlpineJS reactive approach
const rideModelInput = this.$el.closest('form').querySelector('#id_ride_model');
const searchInput = this.$el.closest('form').querySelector('#id_ride_model_search');
const resultsDiv = this.$el.closest('form').querySelector('#ride-model-search-results');
if (rideModelInput) rideModelInput.value = id;
if (searchInput) searchInput.value = name;
if (resultsDiv) resultsDiv.innerHTML = '';
// Dispatch custom event for parent component
this.$dispatch('ride-model-selected', { id, name });
}
}));
});
</script>
<div x-data="rideModelSearchResults()"
@click.outside="$el.innerHTML = ''"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600"
style="max-height: 240px; overflow-y: auto;">
{% if not manufacturer_id %}
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
Please select a manufacturer first
@@ -8,7 +31,7 @@
{% for ride_model in ride_models %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectRideModel('{{ ride_model.id }}', '{{ ride_model.name|escapejs }}')">
@click="selectRideModel('{{ ride_model.id }}', '{{ ride_model.name|escapejs }}')">
{{ ride_model.name }}
{% if ride_model.manufacturer %}
<div class="text-sm text-gray-700 dark:text-gray-300">
@@ -28,11 +51,3 @@
{% endif %}
{% endif %}
</div>
<script>
function selectRideModel(id, name) {
document.getElementById('id_ride_model').value = id;
document.getElementById('id_ride_model_search').value = name;
document.getElementById('ride-model-search-results').innerHTML = '';
}
</script>

View File

@@ -1,365 +1,122 @@
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('rideSearch', () => ({
init() {
// Initialize from URL params
const urlParams = new URLSearchParams(window.location.search);
this.searchQuery = urlParams.get('search') || '';
// Bind to form reset
document.querySelector('form').addEventListener('reset', () => {
this.searchQuery = '';
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
<div x-data="{
searchQuery: new URLSearchParams(window.location.search).get('search') || '',
showSuggestions: false,
selectedIndex: -1,
init() {
// Watch for URL changes
this.$watch('searchQuery', value => {
if (value.length >= 2) {
this.showSuggestions = true;
} else {
this.showSuggestions = false;
}
});
// Handle clicks outside to close suggestions
this.$el.addEventListener('click', (e) => {
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) {
this.showSuggestions = false;
}
});
},
handleInput() {
// HTMX will handle the actual search request
if (this.searchQuery.length >= 2) {
this.showSuggestions = true;
} else {
this.showSuggestions = false;
}
},
selectSuggestion(text) {
this.searchQuery = text;
this.showSuggestions = false;
// Update the search input
this.$refs.searchInput.value = text;
// Trigger form change for HTMX
this.$refs.searchForm.dispatchEvent(new Event('change'));
},
handleKeydown(e) {
const suggestions = this.$el.querySelectorAll('#search-suggestions button');
if (!suggestions.length) return;
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
if (this.selectedIndex < suggestions.length - 1) {
this.selectedIndex++;
suggestions[this.selectedIndex].focus();
}
break;
case 'ArrowUp':
e.preventDefault();
if (this.selectedIndex > 0) {
this.selectedIndex--;
suggestions[this.selectedIndex].focus();
} else {
this.$refs.searchInput.focus();
this.selectedIndex = -1;
}
break;
case 'Escape':
this.showSuggestions = false;
this.selectedIndex = -1;
this.cleanup();
});
// Handle clicks outside suggestions
document.addEventListener('click', (e) => {
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) {
this.showSuggestions = false;
this.$refs.searchInput.blur();
break;
case 'Enter':
if (e.target.tagName === 'BUTTON') {
e.preventDefault();
this.selectSuggestion(e.target.dataset.text);
}
});
// Handle HTMX errors
document.body.addEventListener('htmx:error', (evt) => {
console.error('HTMX Error:', evt.detail.error);
this.showError('An error occurred while searching. Please try again.');
});
// Store bound handlers for cleanup
this.boundHandlers = new Map();
// Create handler functions
const popstateHandler = () => {
const urlParams = new URLSearchParams(window.location.search);
this.searchQuery = urlParams.get('search') || '';
this.syncFormWithUrl();
};
this.boundHandlers.set('popstate', popstateHandler);
const errorHandler = (evt) => {
console.error('HTMX Error:', evt.detail.error);
this.showError('An error occurred while searching. Please try again.');
};
this.boundHandlers.set('htmx:error', errorHandler);
// Bind event listeners
window.addEventListener('popstate', popstateHandler);
document.body.addEventListener('htmx:error', errorHandler);
// Restore filters from localStorage if no URL params exist
const savedFilters = localStorage.getItem('rideFilters');
// Set up destruction handler
this.$cleanup = this.performCleanup.bind(this);
if (savedFilters) {
const filters = JSON.parse(savedFilters);
Object.entries(filters).forEach(([key, value]) => {
const input = document.querySelector(`[name="${key}"]`);
if (input) input.value = value;
});
// Trigger search with restored filters
document.querySelector('form').dispatchEvent(new Event('change'));
}
// Set up filter persistence
document.querySelector('form').addEventListener('change', (e) => {
this.saveFilters();
});
},
showSuggestions: false,
loading: false,
searchQuery: '',
suggestionTimeout: null,
// Save current filters to localStorage
saveFilters() {
const form = document.querySelector('form');
const formData = new FormData(form);
const filters = {};
for (let [key, value] of formData.entries()) {
if (value) filters[key] = value;
}
localStorage.setItem('rideFilters', JSON.stringify(filters));
},
// Clear all filters
clearFilters() {
document.querySelectorAll('form select, form input').forEach(el => {
el.value = '';
});
localStorage.removeItem('rideFilters');
document.querySelector('form').dispatchEvent(new Event('change'));
},
// Get search suggestions with request tracking
lastRequestId: 0,
currentRequest: null,
getSearchSuggestions() {
if (this.searchQuery.length < 2) {
break;
case 'Tab':
this.showSuggestions = false;
return;
}
// Cancel any pending request
if (this.currentRequest) {
this.currentRequest.abort();
}
const requestId = ++this.lastRequestId;
const controller = new AbortController();
this.currentRequest = controller;
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
this.fetchSuggestions(controller, requestId, () => {
clearTimeout(timeoutId);
if (this.currentRequest === controller) {
this.currentRequest = null;
}
});
},
fetchSuggestions(controller, requestId) {
const parkSlug = document.querySelector('input[name="park_slug"]')?.value;
const queryParams = {
q: this.searchQuery
};
if (parkSlug) {
queryParams.park_slug = parkSlug;
}
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-get', '/rides/search-suggestions/');
tempForm.setAttribute('hx-vals', JSON.stringify(queryParams));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add request ID header simulation
tempForm.setAttribute('hx-headers', JSON.stringify({
'X-Request-ID': requestId.toString()
}));
// Handle abort signal
if (controller.signal.aborted) {
this.handleSuggestionError(new Error('AbortError'), requestId);
return;
}
const abortHandler = () => {
if (document.body.contains(tempForm)) {
document.body.removeChild(tempForm);
}
this.handleSuggestionError(new Error('AbortError'), requestId);
};
controller.signal.addEventListener('abort', abortHandler);
tempForm.addEventListener('htmx:afterRequest', (event) => {
controller.signal.removeEventListener('abort', abortHandler);
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
this.handleSuggestionResponse(event.detail.xhr, requestId);
} else {
this.handleSuggestionError(new Error(`HTTP error! status: ${event.detail.xhr.status}`), requestId);
}
if (document.body.contains(tempForm)) {
document.body.removeChild(tempForm);
}
});
tempForm.addEventListener('htmx:error', (event) => {
controller.signal.removeEventListener('abort', abortHandler);
this.handleSuggestionError(new Error(`HTTP error! status: ${event.detail.xhr.status || 'unknown'}`), requestId);
if (document.body.contains(tempForm)) {
document.body.removeChild(tempForm);
}
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
},
handleSuggestionResponse(xhr, requestId) {
if (requestId === this.lastRequestId && this.searchQuery === document.getElementById('search').value) {
const html = xhr.responseText || '';
const suggestionsEl = document.getElementById('search-suggestions');
suggestionsEl.innerHTML = html;
this.showSuggestions = Boolean(html.trim());
this.updateAriaAttributes(suggestionsEl);
}
},
updateAriaAttributes(suggestionsEl) {
const searchInput = document.getElementById('search');
searchInput.setAttribute('aria-expanded', this.showSuggestions.toString());
searchInput.setAttribute('aria-controls', 'search-suggestions');
if (this.showSuggestions) {
suggestionsEl.setAttribute('role', 'listbox');
suggestionsEl.querySelectorAll('button').forEach(btn => {
btn.setAttribute('role', 'option');
});
}
},
handleSuggestionError(error, requestId) {
if (error.name === 'AbortError') {
console.warn('Search suggestion request timed out or cancelled');
return;
}
console.error('Error fetching suggestions:', error);
if (requestId === this.lastRequestId) {
const suggestionsEl = document.getElementById('search-suggestions');
suggestionsEl.innerHTML = `
<div class="p-2 text-sm text-red-600 dark:text-red-400" role="alert">
Failed to load suggestions. Please try again.
</div>`;
this.showSuggestions = true;
}
},
// Handle input changes with debounce
handleInput() {
clearTimeout(this.suggestionTimeout);
this.suggestionTimeout = setTimeout(() => {
this.getSearchSuggestions();
}, 200);
},
// Handle suggestion selection
// Sync form with URL parameters
syncFormWithUrl() {
const urlParams = new URLSearchParams(window.location.search);
const form = document.querySelector('form');
// Clear existing values
form.querySelectorAll('input, select').forEach(el => {
if (el.type !== 'hidden') el.value = '';
});
// Set values from URL
urlParams.forEach((value, key) => {
const input = form.querySelector(`[name="${key}"]`);
if (input) input.value = value;
});
// Trigger form update
form.dispatchEvent(new Event('change'));
},
// Cleanup resources
cleanup() {
clearTimeout(this.suggestionTimeout);
this.showSuggestions = false;
localStorage.removeItem('rideFilters');
},
selectSuggestion(text) {
this.searchQuery = text;
this.showSuggestions = false;
document.getElementById('search').value = text;
// Update URL with search parameter
const url = new URL(window.location);
url.searchParams.set('search', text);
window.history.pushState({}, '', url);
document.querySelector('form').dispatchEvent(new Event('change'));
},
// Handle keyboard navigation
// Show error message
showError(message) {
const searchInput = document.getElementById('search');
const errorDiv = document.createElement('div');
errorDiv.className = 'text-red-600 text-sm mt-1';
errorDiv.textContent = message;
searchInput.parentNode.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 3000);
},
// Handle keyboard navigation
handleKeydown(e) {
const suggestions = document.querySelectorAll('#search-suggestions button');
if (!suggestions.length) return;
const currentIndex = Array.from(suggestions).findIndex(el => el === document.activeElement);
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
if (currentIndex < 0) {
suggestions[0].focus();
this.selectedIndex = 0;
} else if (currentIndex < suggestions.length - 1) {
suggestions[currentIndex + 1].focus();
this.selectedIndex = currentIndex + 1;
}
break;
case 'ArrowUp':
e.preventDefault();
if (currentIndex > 0) {
suggestions[currentIndex - 1].focus();
this.selectedIndex = currentIndex - 1;
} else {
document.getElementById('search').focus();
this.selectedIndex = -1;
}
break;
case 'Escape':
this.showSuggestions = false;
this.selectedIndex = -1;
document.getElementById('search').blur();
break;
case 'Enter':
if (document.activeElement.tagName === 'BUTTON') {
e.preventDefault();
this.selectSuggestion(document.activeElement.dataset.text);
}
break;
case 'Tab':
this.showSuggestions = false;
break;
}
break;
}
}));
});
},
}
}"
@click.outside="showSuggestions = false">
performCleanup() {
// Remove all bound event listeners
this.boundHandlers.forEach(this.removeEventHandler.bind(this));
this.boundHandlers.clear();
// Cancel any pending requests
if (this.currentRequest) {
this.currentRequest.abort();
this.currentRequest = null;
}
// Clear any pending timeouts
if (this.suggestionTimeout) {
clearTimeout(this.suggestionTimeout);
}
},
removeEventHandler(handler, event) {
if (event === 'popstate') {
window.removeEventListener(event, handler);
} else {
document.body.removeEventListener(event, handler);
}
}
}));
});
</script>
<!-- Search Input with HTMX -->
<input
x-ref="searchInput"
x-model="searchQuery"
@input="handleInput()"
@keydown="handleKeydown($event)"
hx-get="/rides/search-suggestions/"
hx-trigger="input changed delay:200ms"
hx-target="#search-suggestions"
hx-swap="innerHTML"
hx-include="[name='park_slug']"
:aria-expanded="showSuggestions"
aria-controls="search-suggestions"
type="text"
name="search"
id="search"
placeholder="Search rides..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<!-- Suggestions Container -->
<div
x-show="showSuggestions"
x-transition
id="search-suggestions"
role="listbox"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
>
<!-- HTMX will populate this -->
</div>
<!-- Form Reference for HTMX -->
<form x-ref="searchForm" style="display: none;">
<!-- Hidden form for HTMX reference -->
</form>
</div>
<!-- HTMX Loading Indicator Styles -->
<style>
@@ -368,10 +125,9 @@ document.addEventListener('alpine:init', () => {
transition: opacity 200ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1
opacity: 1;
}
/* Enhanced Loading Indicator */
.loading-indicator {
position: fixed;
bottom: 1rem;
@@ -396,60 +152,14 @@ document.addEventListener('alpine:init', () => {
}
</style>
<script>
// Initialize request timeout management
const timeouts = new Map();
// Handle request start
document.addEventListener('htmx:beforeRequest', function(evt) {
const timestamp = document.querySelector('.loading-timestamp');
if (timestamp) {
timestamp.textContent = new Date().toLocaleTimeString();
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
<div x-data="{
init() {
// Only essential HTMX error handling as shown in Context7 docs
this.$el.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
console.error('HTMX Error:', evt.detail.xhr.status);
}
});
}
// Set timeout for request
const timeoutId = setTimeout(() => {
evt.detail.xhr.abort();
showError('Request timed out. Please try again.');
}, 10000); // 10s timeout
timeouts.set(evt.detail.xhr, timeoutId);
});
// Handle request completion
document.addEventListener('htmx:afterRequest', function(evt) {
const timeoutId = timeouts.get(evt.detail.xhr);
if (timeoutId) {
clearTimeout(timeoutId);
timeouts.delete(evt.detail.xhr);
}
if (!evt.detail.successful) {
showError('Failed to update results. Please try again.');
}
});
// Handle errors
function showError(message) {
const indicator = document.querySelector('.loading-indicator');
if (indicator) {
indicator.innerHTML = `
<div class="flex items-center text-red-100">
<i class="mr-2 fas fa-exclamation-circle"></i>
<span>${message}</span>
</div>`;
setTimeout(() => {
indicator.innerHTML = originalIndicatorContent;
}, 3000);
}
}
// Store original indicator content
const originalIndicatorContent = document.querySelector('.loading-indicator')?.innerHTML;
// Reset loading state when navigating away
window.addEventListener('beforeunload', () => {
timeouts.forEach(timeoutId => clearTimeout(timeoutId));
timeouts.clear();
});
</script>
}"></div>

View File

@@ -15,26 +15,7 @@
{% endif %}
</h1>
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="{
status: '{{ form.instance.status|default:'OPERATING' }}',
clearResults(containerId) {
const container = document.getElementById(containerId);
if (container && !container.contains(event.target)) {
container.querySelector('[id$=search-results]').innerHTML = '';
}
},
handleStatusChange(event) {
this.status = event.target.value;
if (this.status === 'CLOSING') {
document.getElementById('id_closing_date').required = true;
} else {
document.getElementById('id_closing_date').required = false;
}
},
showClosingDate() {
return ['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(this.status);
}
}">
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="rideFormData()">
{% csrf_token %}
{% if not park %}
@@ -242,4 +223,41 @@
</form>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('rideFormData', () => ({
status: '{{ form.instance.status|default:"OPERATING" }}',
init() {
// Watch for status changes on the status select element
this.$watch('status', (value) => {
const closingDateField = this.$el.querySelector('#id_closing_date');
if (closingDateField) {
closingDateField.required = value === 'CLOSING';
}
});
},
clearResults(containerId) {
// Use AlpineJS $el to find container within component scope
const container = this.$el.querySelector(`#${containerId}`);
if (container) {
const resultsDiv = container.querySelector('[id$="search-results"]');
if (resultsDiv) {
resultsDiv.innerHTML = '';
}
}
},
handleStatusChange(event) {
this.status = event.target.value;
},
showClosingDate() {
return ['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(this.status);
}
}));
});
</script>
{% endblock %}

View File

@@ -6,8 +6,22 @@
{% block meta_description %}Find your perfect theme park adventure with our advanced search. Filter by location, thrill level, ride type, and more to discover exactly what you're looking for.{% endblock %}
{% block content %}
<!-- Advanced Search Page -->
<div class="min-h-screen bg-gradient-to-br from-white via-blue-50/30 to-indigo-50/30 dark:from-gray-950 dark:via-indigo-950/30 dark:to-purple-950/30">
<!-- Advanced Search Page - HTMX + AlpineJS ONLY -->
<div class="min-h-screen bg-gradient-to-br from-white via-blue-50/30 to-indigo-50/30 dark:from-gray-950 dark:via-indigo-950/30 dark:to-purple-950/30"
x-data="{
searchType: 'parks',
viewMode: 'grid',
toggleSearchType(type) {
this.searchType = type;
// Use HTMX to update filters
htmx.trigger('#filter-form', 'change');
},
setViewMode(mode) {
this.viewMode = mode;
}
}">
<!-- Search Header -->
<section class="py-16 bg-gradient-to-r from-thrill-primary/10 via-purple-500/10 to-pink-500/10 backdrop-blur-sm">
@@ -25,12 +39,12 @@
<!-- Quick Search Bar -->
<div class="relative max-w-2xl mx-auto">
<input type="text"
id="quick-search"
placeholder="Quick search: parks, rides, locations..."
class="w-full pl-16 pr-6 py-4 bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm border border-neutral-300/50 dark:border-neutral-600/50 rounded-2xl text-lg shadow-lg focus:shadow-xl focus:bg-white dark:focus:bg-neutral-800 focus:border-thrill-primary focus:ring-2 focus:ring-thrill-primary/20 transition-all duration-300"
hx-get="/search/quick/"
hx-trigger="keyup changed delay:300ms"
hx-target="#quick-results">
hx-target="#quick-results"
hx-swap="innerHTML">
<div class="absolute left-6 top-1/2 transform -translate-y-1/2">
<i class="fas fa-search text-2xl text-thrill-primary"></i>
</div>
@@ -55,7 +69,7 @@
Filters
</h2>
<form id="advanced-search-form"
<form id="filter-form"
hx-get="/search/results/"
hx-target="#search-results"
hx-trigger="change, submit"
@@ -66,18 +80,30 @@
<div class="form-group">
<label class="form-label">Search For</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex items-center p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-thrill-primary/5 transition-colors">
<input type="radio" name="search_type" value="parks" checked class="sr-only">
<label class="flex items-center p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-thrill-primary/5 transition-colors"
:class="{ 'bg-thrill-primary/10 border-thrill-primary': searchType === 'parks' }">
<input type="radio"
name="search_type"
value="parks"
x-model="searchType"
class="sr-only">
<div class="w-4 h-4 border-2 border-thrill-primary rounded-full mr-3 flex items-center justify-center">
<div class="w-2 h-2 bg-thrill-primary rounded-full opacity-0 transition-opacity"></div>
<div class="w-2 h-2 bg-thrill-primary rounded-full transition-opacity"
:class="{ 'opacity-100': searchType === 'parks', 'opacity-0': searchType !== 'parks' }"></div>
</div>
<i class="fas fa-map-marked-alt mr-2 text-thrill-primary"></i>
Parks
</label>
<label class="flex items-center p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-thrill-secondary/5 transition-colors">
<input type="radio" name="search_type" value="rides" class="sr-only">
<label class="flex items-center p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-thrill-secondary/5 transition-colors"
:class="{ 'bg-thrill-secondary/10 border-thrill-secondary': searchType === 'rides' }">
<input type="radio"
name="search_type"
value="rides"
x-model="searchType"
class="sr-only">
<div class="w-4 h-4 border-2 border-thrill-secondary rounded-full mr-3 flex items-center justify-center">
<div class="w-2 h-2 bg-thrill-secondary rounded-full opacity-0 transition-opacity"></div>
<div class="w-2 h-2 bg-thrill-secondary rounded-full transition-opacity"
:class="{ 'opacity-100': searchType === 'rides', 'opacity-0': searchType !== 'rides' }"></div>
</div>
<i class="fas fa-rocket mr-2 text-thrill-secondary"></i>
Rides
@@ -109,7 +135,7 @@
</div>
<!-- Park-Specific Filters -->
<div id="park-filters" class="space-y-6">
<div x-show="searchType === 'parks'" x-transition class="space-y-6">
<div class="form-group">
<label class="form-label">Park Type</label>
<select name="park_type" class="form-select">
@@ -125,62 +151,56 @@
<label class="form-label">Park Status</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" name="status" value="OPERATING" checked class="sr-only">
<div class="checkbox-custom mr-3"></div>
<span class="badge-operating">Operating</span>
<input type="checkbox" name="status" value="OPERATING" checked class="form-checkbox">
<span class="badge-operating ml-2">Operating</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="status" value="CONSTRUCTION" class="sr-only">
<div class="checkbox-custom mr-3"></div>
<span class="badge-construction">Under Construction</span>
<input type="checkbox" name="status" value="CONSTRUCTION" class="form-checkbox">
<span class="badge-construction ml-2">Under Construction</span>
</label>
</div>
</div>
<div class="form-group">
<label class="form-label">Minimum Rides</label>
<input type="range" name="min_rides" min="0" max="100" value="0" class="w-full">
<input type="range" name="min_rides" min="0" max="100" value="0" class="w-full form-range">
<div class="flex justify-between text-sm text-neutral-500 mt-1">
<span>0</span>
<span id="min-rides-value">0</span>
<span x-text="$el.querySelector('input[name=min_rides]')?.value || '0'"></span>
<span>100+</span>
</div>
</div>
</div>
<!-- Ride-Specific Filters -->
<div id="ride-filters" class="space-y-6 hidden">
<div x-show="searchType === 'rides'" x-transition class="space-y-6">
<div class="form-group">
<label class="form-label">Thrill Level</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" name="thrill_level" value="MILD" class="sr-only">
<div class="checkbox-custom mr-3"></div>
<span class="badge bg-green-500/10 text-green-600 border-green-500/20">
<input type="checkbox" name="thrill_level" value="MILD" class="form-checkbox">
<span class="badge bg-green-500/10 text-green-600 border-green-500/20 ml-2">
<i class="fas fa-leaf mr-1"></i>
Family Friendly
</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="thrill_level" value="MODERATE" class="sr-only">
<div class="checkbox-custom mr-3"></div>
<span class="badge bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
<input type="checkbox" name="thrill_level" value="MODERATE" class="form-checkbox">
<span class="badge bg-yellow-500/10 text-yellow-600 border-yellow-500/20 ml-2">
<i class="fas fa-star mr-1"></i>
Moderate
</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="thrill_level" value="HIGH" class="sr-only">
<div class="checkbox-custom mr-3"></div>
<span class="badge bg-orange-500/10 text-orange-600 border-orange-500/20">
<input type="checkbox" name="thrill_level" value="HIGH" class="form-checkbox">
<span class="badge bg-orange-500/10 text-orange-600 border-orange-500/20 ml-2">
<i class="fas fa-bolt mr-1"></i>
High Thrill
</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="thrill_level" value="EXTREME" class="sr-only">
<div class="checkbox-custom mr-3"></div>
<span class="badge bg-red-500/10 text-red-600 border-red-500/20">
<input type="checkbox" name="thrill_level" value="EXTREME" class="form-checkbox">
<span class="badge bg-red-500/10 text-red-600 border-red-500/20 ml-2">
<i class="fas fa-fire mr-1"></i>
Extreme
</span>
@@ -202,20 +222,20 @@
<div class="form-group">
<label class="form-label">Minimum Height (ft)</label>
<input type="range" name="min_height" min="0" max="500" value="0" class="w-full">
<input type="range" name="min_height" min="0" max="500" value="0" class="w-full form-range">
<div class="flex justify-between text-sm text-neutral-500 mt-1">
<span>0ft</span>
<span id="min-height-value">0ft</span>
<span x-text="($el.querySelector('input[name=min_height]')?.value || '0') + 'ft'"></span>
<span>500ft+</span>
</div>
</div>
<div class="form-group">
<label class="form-label">Minimum Speed (mph)</label>
<input type="range" name="min_speed" min="0" max="150" value="0" class="w-full">
<input type="range" name="min_speed" min="0" max="150" value="0" class="w-full form-range">
<div class="flex justify-between text-sm text-neutral-500 mt-1">
<span>0mph</span>
<span id="min-speed-value">0mph</span>
<span x-text="($el.querySelector('input[name=min_speed]')?.value || '0') + 'mph'"></span>
<span>150mph+</span>
</div>
</div>
@@ -236,9 +256,11 @@
</div>
<!-- Clear Filters -->
<button type="button"
id="clear-filters"
class="btn-ghost w-full">
<button type="reset"
class="btn-ghost w-full"
hx-get="/search/results/"
hx-target="#search-results"
hx-swap="innerHTML">
<i class="fas fa-times mr-2"></i>
Clear All Filters
</button>
@@ -252,24 +274,30 @@
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold">Search Results</h2>
<p class="text-neutral-600 dark:text-neutral-400" id="results-count">
<p class="text-neutral-600 dark:text-neutral-400">
Use filters to find your perfect adventure
</p>
</div>
<!-- View Toggle -->
<div class="flex items-center space-x-2 bg-white dark:bg-neutral-800 rounded-lg p-1 border border-neutral-200 dark:border-neutral-700">
<button class="p-2 rounded-md bg-thrill-primary text-white" id="grid-view">
<button class="p-2 rounded-md transition-colors"
:class="{ 'bg-thrill-primary text-white': viewMode === 'grid', 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-700': viewMode !== 'grid' }"
@click="setViewMode('grid')">
<i class="fas fa-th-large"></i>
</button>
<button class="p-2 rounded-md text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-700" id="list-view">
<button class="p-2 rounded-md transition-colors"
:class="{ 'bg-thrill-primary text-white': viewMode === 'list', 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-700': viewMode !== 'list' }"
@click="setViewMode('list')">
<i class="fas fa-list"></i>
</button>
</div>
</div>
<!-- Search Results Container -->
<div id="search-results" class="min-h-96">
<div id="search-results"
class="min-h-96"
:class="{ 'grid-view': viewMode === 'grid', 'list-view': viewMode === 'list' }">
<!-- Initial State -->
<div class="text-center py-16">
<div class="w-24 h-24 bg-gradient-to-r from-thrill-primary to-purple-500 rounded-full flex items-center justify-center mx-auto mb-6">
@@ -283,7 +311,7 @@
</div>
<!-- Load More Button -->
<div id="load-more-container" class="text-center mt-8 hidden">
<div class="text-center mt-8 hidden" id="load-more-container">
<button class="btn-secondary btn-lg"
hx-get="/search/results/"
hx-target="#search-results"
@@ -298,51 +326,8 @@
</section>
</div>
<!-- AlpineJS Advanced Search Component (HTMX + AlpineJS Only) -->
<div x-data="{
searchType: 'parks',
viewMode: 'grid',
toggleSearchType(type) {
this.searchType = type;
},
clearFilters() {
document.getElementById('advanced-search-form').reset();
this.searchType = 'parks';
},
setViewMode(mode) {
this.viewMode = mode;
}
}" style="display: none;">
<!-- Advanced search functionality handled by AlpineJS + HTMX -->
</div>
<!-- Custom CSS for checkboxes and enhanced styling -->
<!-- Custom CSS for enhanced styling -->
<style>
.checkbox-custom {
width: 1rem;
height: 1rem;
border: 2px solid #cbd5e1;
border-radius: 0.25rem;
position: relative;
transition: all 0.2s;
}
.checkbox-custom.checked {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-color: #6366f1;
}
.checkbox-custom.checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 0.75rem;
font-weight: bold;
}
.grid-view .search-results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));

View File

@@ -93,7 +93,7 @@
.status-pending { background: #f59e0b; }
</style>
</head>
<body class="bg-gray-50 p-8">
<body class="bg-gray-50 p-8" x-data="authModalTestSuite()">
<div class="max-w-7xl mx-auto">
<h1 class="text-3xl font-bold mb-8 text-center">Auth Modal Component Comparison Test</h1>
<p class="text-center text-gray-600 mb-8">Comparing original include method vs new cotton component for Auth Modal with full Alpine.js functionality</p>
@@ -119,7 +119,7 @@
<div class="modal-test-container">
<div class="modal-test-group" data-label="Original Include Version">
<button class="test-button" onclick="if(window.authModalOriginal) window.authModalOriginal.open = true">
<button class="test-button" @click="openOriginalModal()">
Open Original Auth Modal
</button>
<div class="feature-list">
@@ -136,7 +136,7 @@
</div>
<div class="modal-test-group" data-label="Cotton Component Version">
<button class="test-button" onclick="if(window.authModalCotton) window.authModalCotton.open = true">
<button class="test-button" @click="openCottonModal()">
Open Cotton Auth Modal
</button>
<div class="feature-list">
@@ -161,10 +161,10 @@
<div class="modal-test-container">
<div class="modal-test-group" data-label="Original Include Version">
<button class="test-button" onclick="openOriginalModalInMode('login')">
<button class="test-button" @click="openOriginalModalInMode('login')">
Open in Login Mode
</button>
<button class="test-button secondary" onclick="openOriginalModalInMode('register')">
<button class="test-button secondary" @click="openOriginalModalInMode('register')">
Open in Register Mode
</button>
<div class="feature-list">
@@ -181,10 +181,10 @@
</div>
<div class="modal-test-group" data-label="Cotton Component Version">
<button class="test-button" onclick="openCottonModalInMode('login')">
<button class="test-button" @click="openCottonModalInMode('login')">
Open in Login Mode
</button>
<button class="test-button secondary" onclick="openCottonModalInMode('register')">
<button class="test-button secondary" @click="openCottonModalInMode('register')">
Open in Register Mode
</button>
<div class="feature-list">
@@ -209,7 +209,7 @@
<div class="modal-test-container">
<div class="modal-test-group" data-label="Original Include Version">
<button class="test-button" onclick="testOriginalInteractivity()">
<button class="test-button" @click="testOriginalInteractivity()">
Test Original Interactions
</button>
<div class="feature-list">
@@ -226,7 +226,7 @@
</div>
<div class="modal-test-group" data-label="Cotton Component Version">
<button class="test-button" onclick="testCottonInteractivity()">
<button class="test-button" @click="testCottonInteractivity()">
Test Cotton Interactions
</button>
<div class="feature-list">
@@ -251,7 +251,7 @@
<div class="modal-test-container">
<div class="modal-test-group" data-label="Styling Verification">
<button class="test-button" onclick="compareModalStyling()">
<button class="test-button" @click="compareModalStyling()">
Compare Both Modals Side by Side
</button>
<div class="feature-list">
@@ -278,7 +278,7 @@
<div class="modal-test-container">
<div class="modal-test-group" data-label="Custom Configuration Test">
<button class="test-button" onclick="testCustomConfiguration()">
<button class="test-button" @click="testCustomConfiguration()">
Test Custom Cotton Config
</button>
<div class="feature-list">
@@ -439,73 +439,89 @@
}));
});
// Store references to both modal instances
document.addEventListener('DOMContentLoaded', function() {
// Wait for Alpine.js to initialize and modal instances to be created
setTimeout(() => {
// Both modals should now be available with their respective window keys
console.log('Auth Modal References:', {
original: window.authModalOriginal,
cotton: window.authModalCotton,
custom: window.authModalCustom
});
}, 500);
});
// Auth Modal Test Suite Component
Alpine.data('authModalTestSuite', () => ({
init() {
// Wait for Alpine.js to initialize and modal instances to be created
setTimeout(() => {
console.log('Auth Modal References:', {
original: window.authModalOriginal,
cotton: window.authModalCotton,
custom: window.authModalCustom
});
}, 500);
},
// Test functions
function openOriginalModalInMode(mode) {
if (window.authModalOriginal) {
window.authModalOriginal.mode = mode;
window.authModalOriginal.open = true;
}
}
openOriginalModal() {
if (window.authModalOriginal) {
window.authModalOriginal.open = true;
}
},
function openCottonModalInMode(mode) {
if (window.authModalCotton) {
window.authModalCotton.mode = mode;
window.authModalCotton.open = true;
}
}
openCottonModal() {
if (window.authModalCotton) {
window.authModalCotton.open = true;
}
},
function testOriginalInteractivity() {
if (window.authModalOriginal) {
window.authModalOriginal.open = true;
window.authModalOriginal.mode = 'login';
setTimeout(() => {
window.authModalOriginal.loginError = 'Test error message';
window.authModalOriginal.showPassword = true;
}, 500);
}
}
openOriginalModalInMode(mode) {
if (window.authModalOriginal) {
window.authModalOriginal.mode = mode;
window.authModalOriginal.open = true;
}
},
function testCottonInteractivity() {
if (window.authModalCotton) {
window.authModalCotton.open = true;
window.authModalCotton.mode = 'login';
setTimeout(() => {
window.authModalCotton.loginError = 'Test error message';
window.authModalCotton.showPassword = true;
}, 500);
}
}
openCottonModalInMode(mode) {
if (window.authModalCotton) {
window.authModalCotton.mode = mode;
window.authModalCotton.open = true;
}
},
function compareModalStyling() {
if (window.authModalOriginal && window.authModalCotton) {
window.authModalOriginal.open = true;
setTimeout(() => {
window.authModalCotton.open = true;
}, 200);
}
}
testOriginalInteractivity() {
if (window.authModalOriginal) {
window.authModalOriginal.open = true;
window.authModalOriginal.mode = 'login';
setTimeout(() => {
window.authModalOriginal.loginError = 'Test error message';
window.authModalOriginal.showPassword = true;
}, 500);
}
},
function testCustomConfiguration() {
// Show the custom cotton modal
const customModal = document.getElementById('custom-cotton-modal');
customModal.style.display = 'block';
// You would implement custom Alpine.js instance here
alert('Custom configuration test - check the modal titles and text changes');
}
testCottonInteractivity() {
if (window.authModalCotton) {
window.authModalCotton.open = true;
window.authModalCotton.mode = 'login';
setTimeout(() => {
window.authModalCotton.loginError = 'Test error message';
window.authModalCotton.showPassword = true;
}, 500);
}
},
compareModalStyling() {
if (window.authModalOriginal && window.authModalCotton) {
window.authModalOriginal.open = true;
setTimeout(() => {
window.authModalCotton.open = true;
}, 200);
}
},
testCustomConfiguration() {
// Show the custom cotton modal
const customModal = this.$el.querySelector('#custom-cotton-modal');
if (customModal) {
customModal.style.display = 'block';
}
// Dispatch custom event for configuration test
this.$dispatch('custom-config-test', {
message: 'Custom configuration test - check the modal titles and text changes'
});
}
}));
</script>
</body>
</html>
</html>

View File

@@ -54,7 +54,7 @@
}
</style>
</head>
<body class="bg-gray-50 p-8">
<body class="bg-gray-50 p-8" x-data="componentTestSuite()">
<div class="max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-8 text-center">UI Component Comparison Test</h1>
<p class="text-center text-gray-600 mb-8">Comparing old include method vs new cotton component method for Button, Input, and Card components</p>
@@ -582,73 +582,95 @@
</div>
</div>
<!-- Alpine.js -->
<script src="{% static 'js/alpine.min.js' %}" defer></script>
<script>
// Function to normalize HTML for comparison
function normalizeHTML(html) {
return html
.replace(/\s+/g, ' ')
.replace(/> </g, '><')
.trim();
}
document.addEventListener('alpine:init', () => {
// Component Test Suite Component
Alpine.data('componentTestSuite', () => ({
init() {
// Extract HTML after Alpine.js initializes
this.$nextTick(() => {
setTimeout(() => this.extractComponentHTML(), 100);
this.addCompareButton();
});
},
// Function to extract HTML from all component containers
function extractComponentHTML() {
const containers = document.querySelectorAll('.button-container');
const includeHTMLs = [];
const cottonHTMLs = [];
let componentIndex = 1;
// Function to normalize HTML for comparison
normalizeHTML(html) {
return html
.replace(/\s+/g, ' ')
.replace(/> </g, '><')
.trim();
},
containers.forEach((container, index) => {
const label = container.getAttribute('data-label');
// Look for button, input, or div (card) elements
const element = container.querySelector('button') ||
container.querySelector('input') ||
container.querySelector('div.rounded-lg');
if (element && label) {
const html = element.outerHTML;
const normalized = normalizeHTML(html);
// Function to extract HTML from all component containers
extractComponentHTML() {
const containers = this.$el.querySelectorAll('.button-container');
const includeHTMLs = [];
const cottonHTMLs = [];
let componentIndex = 1;
containers.forEach((container, index) => {
const label = container.getAttribute('data-label');
// Look for button, input, or div (card) elements
const element = container.querySelector('button') ||
container.querySelector('input') ||
container.querySelector('div.rounded-lg');
if (element && label) {
const html = element.outerHTML;
const normalized = this.normalizeHTML(html);
if (label === 'Include Version') {
includeHTMLs.push(`<!-- Component ${componentIndex} -->\n${normalized}\n`);
} else if (label === 'Cotton Version') {
cottonHTMLs.push(`<!-- Component ${componentIndex} -->\n${normalized}\n`);
componentIndex++;
}
}
});
const includeElement = this.$el.querySelector('#include-html');
const cottonElement = this.$el.querySelector('#cotton-html');
if (label === 'Include Version') {
includeHTMLs.push(`<!-- Component ${componentIndex} -->\n${normalized}\n`);
} else if (label === 'Cotton Version') {
cottonHTMLs.push(`<!-- Component ${componentIndex} -->\n${normalized}\n`);
componentIndex++;
if (includeElement) includeElement.textContent = includeHTMLs.join('\n');
if (cottonElement) cottonElement.textContent = cottonHTMLs.join('\n');
},
// Function to compare HTML outputs
compareHTML() {
const includeHTML = this.$el.querySelector('#include-html')?.textContent || '';
const cottonHTML = this.$el.querySelector('#cotton-html')?.textContent || '';
if (includeHTML === cottonHTML) {
this.$dispatch('comparison-result', {
success: true,
message: '✅ HTML outputs are identical!'
});
} else {
this.$dispatch('comparison-result', {
success: false,
message: '❌ HTML outputs differ. Check the HTML Output section for details.',
includeHTML,
cottonHTML
});
console.log('Include HTML:', includeHTML);
console.log('Cotton HTML:', cottonHTML);
}
},
// Add compare button
addCompareButton() {
const compareBtn = document.createElement('button');
compareBtn.textContent = 'Compare HTML Outputs';
compareBtn.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 rounded shadow-lg hover:bg-blue-600';
compareBtn.addEventListener('click', () => this.compareHTML());
document.body.appendChild(compareBtn);
}
});
document.getElementById('include-html').textContent = includeHTMLs.join('\n');
document.getElementById('cotton-html').textContent = cottonHTMLs.join('\n');
}
// Extract HTML after page loads
document.addEventListener('DOMContentLoaded', function() {
setTimeout(extractComponentHTML, 100);
});
// Function to compare HTML outputs
function compareHTML() {
const includeHTML = document.getElementById('include-html').textContent;
const cottonHTML = document.getElementById('cotton-html').textContent;
if (includeHTML === cottonHTML) {
alert('✅ HTML outputs are identical!');
} else {
alert('❌ HTML outputs differ. Check the HTML Output section for details.');
console.log('Include HTML:', includeHTML);
console.log('Cotton HTML:', cottonHTML);
}
}
// Add compare button
document.addEventListener('DOMContentLoaded', function() {
const compareBtn = document.createElement('button');
compareBtn.textContent = 'Compare HTML Outputs';
compareBtn.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 rounded shadow-lg hover:bg-blue-600';
compareBtn.onclick = compareHTML;
document.body.appendChild(compareBtn);
}));
});
</script>
</body>
</html>
</html>