Compare commits

...

12 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
32 changed files with 1642 additions and 1906 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. Theme park database platform with Django REST Framework serving 120+ API endpoints for parks, rides, companies, and users.
## Core Architecture ## Core Architecture
- **Backend**: Django 5.0+, DRF, PostgreSQL+PostGIS, Redis, Celery - **Backend**: Django 5.1+, DRF, PostgreSQL+PostGIS, Redis, Celery
- **Frontend**: HTMX + AlpineJS + Tailwind CSS + Django-Cotton - **Frontend**: HTMX (V2+) + AlpineJS + Tailwind CSS (V4+) + Django-Cotton
- 🚨 **ABSOLUTELY NO Custom JS** - use HTMX + AlpineJS ONLY - 🚨 **ABSOLUTELY NO Custom JS** - use HTMX + AlpineJS ONLY
- Clean, simple UX preferred - Clean, simple UX preferred
- **Media**: Cloudflare Images with Direct Upload - **Media**: Cloudflare Images with Direct Upload
@@ -50,3 +50,7 @@ tailwindcss, django, django-cotton, htmx, alpinejs, django-rest-framework, postg
- Real database data only (NO MOCKING) - Real database data only (NO MOCKING)
- RichChoiceField over Django choices - 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 django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter from django.http import HttpRequest
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter 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.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
User = get_user_model() User = get_user_model()
class CustomAccountAdapter(DefaultAccountAdapter): 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. Whether to allow sign ups.
""" """
return True 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. Constructs the email confirmation (activation) url.
""" """
get_current_site(request) 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. Sends the confirmation email.
""" """
current_site = get_current_site(request) current_site = get_current_site(request)
activate_url = self.get_email_confirmation_url(request, emailconfirmation) activate_url = self.get_email_confirmation_url(request, emailconfirmation)
ctx = { # Cast key to str for typing consistency and template context
"user": emailconfirmation.email_address.user, key = cast(str, getattr(emailconfirmation, "key", ""))
"activate_url": activate_url,
"current_site": current_site, # Determine template early
"key": emailconfirmation.key,
}
if signup: if signup:
email_template = "account/email/email_confirmation_signup" email_template = "account/email/email_confirmation_signup"
else: else:
email_template = "account/email/email_confirmation" 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): 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. Whether to allow social account sign ups.
""" """
return True 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. Hook that can be used to further populate the user instance.
""" """
user = super().populate_user(request, sociallogin, data) user = super().populate_user(request, sociallogin, data) # type: ignore
if sociallogin.account.provider == "discord": if getattr(sociallogin.account, "provider", None) == "discord": # type: ignore
user.discord_id = sociallogin.account.uid user.discord_id = getattr(sociallogin.account, "uid", None) # type: ignore
return user 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. Save the newly signed up social login.
""" """
user = super().save_user(request, sociallogin, form) user = super().save_user(request, sociallogin, form) # type: ignore
return user 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 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.utils.html import format_html
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.http import HttpRequest
from django.db.models import QuerySet
from .models import ( from .models import (
User, User,
UserProfile, UserProfile,
@@ -12,7 +15,7 @@ from .models import (
) )
class UserProfileInline(admin.StackedInline): class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite]):
model = UserProfile model = UserProfile
can_delete = False can_delete = False
verbose_name_plural = "Profile" verbose_name_plural = "Profile"
@@ -39,7 +42,7 @@ class UserProfileInline(admin.StackedInline):
) )
class TopListItemInline(admin.TabularInline): class TopListItemInline(admin.TabularInline[TopListItem]):
model = TopListItem model = TopListItem
extra = 1 extra = 1
fields = ("content_type", "object_id", "rank", "notes") fields = ("content_type", "object_id", "rank", "notes")
@@ -47,7 +50,7 @@ class TopListItemInline(admin.TabularInline):
@admin.register(User) @admin.register(User)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(DjangoUserAdmin[User]):
list_display = ( list_display = (
"username", "username",
"email", "email",
@@ -74,7 +77,7 @@ class CustomUserAdmin(UserAdmin):
"ban_users", "ban_users",
"unban_users", "unban_users",
] ]
inlines = [UserProfileInline] inlines: list[type[admin.StackedInline[UserProfile]]] = [UserProfileInline]
fieldsets = ( fieldsets = (
(None, {"fields": ("username", "password")}), (None, {"fields": ("username", "password")}),
@@ -126,75 +129,82 @@ class CustomUserAdmin(UserAdmin):
) )
@admin.display(description="Avatar") @admin.display(description="Avatar")
def get_avatar(self, obj): def get_avatar(self, obj: User) -> str:
if obj.profile.avatar: profile = getattr(obj, "profile", None)
if profile and getattr(profile, "avatar", None):
return format_html( return format_html(
'<img src="{}" width="30" height="30" style="border-radius:50%;" />', '<img src="{0}" width="30" height="30" style="border-radius:50%;" />',
obj.profile.avatar.url, getattr(profile.avatar, "url", ""), # type: ignore
) )
return format_html( return format_html(
'<div style="width:30px; height:30px; border-radius:50%; ' '<div style="width:30px; height:30px; border-radius:50%; '
"background-color:#007bff; color:white; display:flex; " "background-color:#007bff; color:white; display:flex; "
'align-items:center; justify-content:center;">{}</div>', 'align-items:center; justify-content:center;">{0}</div>',
obj.username[0].upper(), getattr(obj, "username", "?")[0].upper(), # type: ignore
) )
@admin.display(description="Status") @admin.display(description="Status")
def get_status(self, obj): def get_status(self, obj: User) -> str:
if obj.is_banned: if getattr(obj, "is_banned", False):
return format_html('<span style="color: red;">Banned</span>') return format_html('<span style="color: red;">{}</span>', "Banned")
if not obj.is_active: if not getattr(obj, "is_active", True):
return format_html('<span style="color: orange;">Inactive</span>') return format_html('<span style="color: orange;">{}</span>', "Inactive")
if obj.is_superuser: if getattr(obj, "is_superuser", False):
return format_html('<span style="color: purple;">Superuser</span>') return format_html('<span style="color: purple;">{}</span>', "Superuser")
if obj.is_staff: if getattr(obj, "is_staff", False):
return format_html('<span style="color: blue;">Staff</span>') return format_html('<span style="color: blue;">{}</span>', "Staff")
return format_html('<span style="color: green;">Active</span>') return format_html('<span style="color: green;">{}</span>', "Active")
@admin.display(description="Ride Credits") @admin.display(description="Ride Credits")
def get_credits(self, obj): def get_credits(self, obj: User) -> str:
try: try:
profile = obj.profile profile = getattr(obj, "profile", None)
if not profile:
return "-"
return format_html( return format_html(
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}", "RC: {0}<br>DR: {1}<br>FR: {2}<br>WR: {3}",
profile.coaster_credits, getattr(profile, "coaster_credits", 0),
profile.dark_ride_credits, getattr(profile, "dark_ride_credits", 0),
profile.flat_ride_credits, getattr(profile, "flat_ride_credits", 0),
profile.water_ride_credits, getattr(profile, "water_ride_credits", 0),
) )
except UserProfile.DoesNotExist: except UserProfile.DoesNotExist:
return "-" return "-"
@admin.action(description="Activate selected users") @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) queryset.update(is_active=True)
@admin.action(description="Deactivate selected users") @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) queryset.update(is_active=False)
@admin.action(description="Ban selected users") @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 from django.utils import timezone
queryset.update(is_banned=True, ban_date=timezone.now()) queryset.update(is_banned=True, ban_date=timezone.now())
@admin.action(description="Unban selected users") @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="") 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 creating = not obj.pk
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
if creating and obj.role != "USER": if creating and getattr(obj, "role", "USER") != "USER":
# Ensure new user with role gets added to appropriate group group = Group.objects.filter(name=getattr(obj, "role", None)).first()
group = Group.objects.filter(name=obj.role).first()
if group: if group:
obj.groups.add(group) obj.groups.add(group) # type: ignore[attr-defined]
@admin.register(UserProfile) @admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin): class UserProfileAdmin(admin.ModelAdmin[UserProfile]):
list_display = ( list_display = (
"user", "user",
"display_name", "display_name",
@@ -235,7 +245,7 @@ class UserProfileAdmin(admin.ModelAdmin):
@admin.register(EmailVerification) @admin.register(EmailVerification)
class EmailVerificationAdmin(admin.ModelAdmin): class EmailVerificationAdmin(admin.ModelAdmin[EmailVerification]):
list_display = ("user", "created_at", "last_sent", "is_expired") list_display = ("user", "created_at", "last_sent", "is_expired")
list_filter = ("created_at", "last_sent") list_filter = ("created_at", "last_sent")
search_fields = ("user__username", "user__email", "token") search_fields = ("user__username", "user__email", "token")
@@ -247,21 +257,21 @@ class EmailVerificationAdmin(admin.ModelAdmin):
) )
@admin.display(description="Status") @admin.display(description="Status")
def is_expired(self, obj): def is_expired(self, obj: EmailVerification) -> str:
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
if timezone.now() - obj.last_sent > timedelta(days=1): if timezone.now() - getattr(obj, "last_sent", timezone.now()) > timedelta(days=1):
return format_html('<span style="color: red;">Expired</span>') return format_html('<span style="color: red;">{}</span>', "Expired")
return format_html('<span style="color: green;">Valid</span>') return format_html('<span style="color: green;">{}</span>', "Valid")
@admin.register(TopList) @admin.register(TopList)
class TopListAdmin(admin.ModelAdmin): class TopListAdmin(admin.ModelAdmin[TopList]):
list_display = ("title", "user", "category", "created_at", "updated_at") list_display = ("title", "user", "category", "created_at", "updated_at")
list_filter = ("category", "created_at", "updated_at") list_filter = ("category", "created_at", "updated_at")
search_fields = ("title", "user__username", "description") search_fields = ("title", "user__username", "description")
inlines = [TopListItemInline] inlines: list[type[admin.TabularInline[TopListItem]]] = [TopListItemInline]
fieldsets = ( fieldsets = (
( (
@@ -277,7 +287,7 @@ class TopListAdmin(admin.ModelAdmin):
@admin.register(TopListItem) @admin.register(TopListItem)
class TopListItemAdmin(admin.ModelAdmin): class TopListItemAdmin(admin.ModelAdmin[TopListItem]):
list_display = ("top_list", "content_type", "object_id", "rank") list_display = ("top_list", "content_type", "object_id", "rank")
list_filter = ("top_list__category", "rank") list_filter = ("top_list__category", "rank")
search_fields = ("top_list__title", "notes") search_fields = ("top_list__title", "notes")
@@ -290,7 +300,7 @@ class TopListItemAdmin(admin.ModelAdmin):
@admin.register(PasswordReset) @admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin): class PasswordResetAdmin(admin.ModelAdmin[PasswordReset]):
"""Admin interface for password reset tokens""" """Admin interface for password reset tokens"""
list_display = ( list_display = (
@@ -341,20 +351,19 @@ class PasswordResetAdmin(admin.ModelAdmin):
) )
@admin.display(description="Status", boolean=True) @admin.display(description="Status", boolean=True)
def is_expired(self, obj): def is_expired(self, obj: PasswordReset) -> str:
"""Display expiration status with color coding"""
from django.utils import timezone from django.utils import timezone
if obj.used: if getattr(obj, "used", False):
return format_html('<span style="color: blue;">Used</span>') return format_html('<span style="color: blue;">{}</span>', "Used")
elif timezone.now() > obj.expires_at: elif timezone.now() > getattr(obj, "expires_at", timezone.now()):
return format_html('<span style="color: red;">Expired</span>') return format_html('<span style="color: red;">{}</span>', "Expired")
return format_html('<span style="color: green;">Valid</span>') 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""" """Disable manual creation of password reset tokens"""
return False 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""" """Allow viewing but restrict editing of password reset tokens"""
return getattr(request.user, "is_superuser", False) return getattr(request.user, "is_superuser", False)

View File

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

View File

@@ -176,3 +176,43 @@ class LocationSuggestionsView(TemplateView):
return JsonResponse({"suggestions": suggestions}) return JsonResponse({"suggestions": suggestions})
except Exception as e: except Exception as e:
return JsonResponse({"error": str(e)}, status=500) 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.db import models
# from django.contrib.gis.geos import Point # Disabled temporarily for setup # from django.contrib.gis.geos import Point # Disabled temporarily for setup
import pghistory import pghistory
from apps.core.history import TrackedModel
@pghistory.track() @pghistory.track()
class ParkLocation(models.Model): class ParkLocation(TrackedModel):
""" """
Represents the geographic location and address of a park, with PostGIS support. Represents the geographic location and address of a park, with PostGIS support.
""" """
@@ -53,15 +54,17 @@ class ParkLocation(models.Model):
@property @property
def latitude(self): def latitude(self):
"""Return latitude from point field.""" """Return latitude from point field."""
if self.point: if self.point and ',' in self.point:
return self.point.y # Temporary string format: "longitude,latitude"
return float(self.point.split(',')[1])
return None return None
@property @property
def longitude(self): def longitude(self):
"""Return longitude from point field.""" """Return longitude from point field."""
if self.point: if self.point and ',' in self.point:
return self.point.x # Temporary string format: "longitude,latitude"
return float(self.point.split(',')[0])
return None return None
@property @property
@@ -97,7 +100,9 @@ class ParkLocation(models.Model):
if not -180 <= longitude <= 180: if not -180 <= longitude <= 180:
raise ValueError("Longitude must be between -180 and 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): def distance_to(self, other_location):
""" """
@@ -106,9 +111,26 @@ class ParkLocation(models.Model):
""" """
if not self.point or not other_location.point: if not self.point or not other_location.point:
return None return None
# Use geodetic distance calculation which returns meters, convert to km
distance_m = self.point.distance(other_location.point) # Temporary implementation using Haversine formula
return distance_m / 1000.0 # 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): def __str__(self):
return f"Location for {self.park.name}" return f"Location for {self.park.name}"

View File

@@ -15,6 +15,7 @@ app_name = "parks"
urlpatterns = [ urlpatterns = [
# Park views with autocomplete search # Park views with autocomplete search
path("", views.ParkListView.as_view(), name="park_list"), 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("operators/", views.OperatorListView.as_view(), name="operator_list"),
path("create/", views.ParkCreateView.as_view(), name="park_create"), path("create/", views.ParkCreateView.as_view(), name="park_create"),
# Add park button endpoint (moved before park detail pattern) # 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 django.shortcuts import get_object_or_404, render
from decimal import InvalidOperation from decimal import InvalidOperation
from django.views.generic import DetailView, ListView, CreateView, UpdateView 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 import requests
from decimal import Decimal, ROUND_DOWN from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional, cast, Literal, Dict 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) 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): class ParkListView(HTMXFilterableMixin, ListView):
model = Park model = Park
template_name = "parks/enhanced_park_list.html" template_name = "parks/enhanced_park_list.html"

View File

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

View File

@@ -302,6 +302,37 @@ class RideListView(ListView):
return context 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): class SingleCategoryListView(ListView):
"""View for displaying rides of a specific category""" """View for displaying rides of a specific category"""

View File

@@ -7,6 +7,7 @@ from datetime import timedelta
import sys import sys
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import List
from decouple import config from decouple import config
# Suppress django-allauth deprecation warnings for dj_rest_auth compatibility # Suppress django-allauth deprecation warnings for dj_rest_auth compatibility
@@ -19,14 +20,14 @@ warnings.filterwarnings(
# Initialize environment variables with better defaults # Initialize environment variables with better defaults
DEBUG = config("DEBUG", default=True) DEBUG = config("DEBUG", default=True, cast=bool)
SECRET_KEY = config("SECRET_KEY") 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") DATABASE_URL = config("DATABASE_URL")
CACHE_URL = config("CACHE_URL", default="locmem://") CACHE_URL = config("CACHE_URL", default="locmem://")
EMAIL_URL = config("EMAIL_URL", default="console://") EMAIL_URL = config("EMAIL_URL", default="console://")
REDIS_URL = config("REDIS_URL", default="redis://127.0.0.1:6379/1") 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_MINUTE = config("API_RATE_LIMIT_PER_MINUTE", default=60)
API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000) API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000)
CACHE_MIDDLEWARE_SECONDS = config("CACHE_MIDDLEWARE_SECONDS", default=300) CACHE_MIDDLEWARE_SECONDS = config("CACHE_MIDDLEWARE_SECONDS", default=300)
@@ -55,7 +56,7 @@ SECRET_KEY = config("SECRET_KEY")
# CSRF trusted origins # CSRF trusted origins
CSRF_TRUSTED_ORIGINS = config( 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 # Application definition

View File

@@ -76,10 +76,33 @@ dev = [
[tool.pyright] [tool.pyright]
stubPath = "stubs" 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] [tool.pylance]
stubPath = "stubs" stubPath = "stubs"
[tool.uv.sources] [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" } 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 --> <!-- Preload Critical Resources -->
{% block critical_resources %} {% block critical_resources %}
<link rel="preload" href="{% static 'css/tailwind.css' %}" as="style" /> <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" /> <link rel="preload" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" as="style" />
{% endblock %} {% endblock %}
@@ -62,10 +61,10 @@
/> />
<!-- HTMX --> <!-- 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) --> <!-- 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 --> <!-- Tailwind CSS -->
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" /> <link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
@@ -173,40 +172,60 @@
<c-toast_container /> <c-toast_container />
<!-- AlpineJS Global Configuration (Compliant with HTMX + AlpineJS Only Rule) --> <!-- AlpineJS Global Configuration (Compliant with HTMX + AlpineJS Only Rule) -->
<div x-data="{}" x-init=" <script>
// Configure HTMX globally document.addEventListener('alpine:init', () => {
htmx.config.globalViewTransitions = true; // Configure HTMX 2.x globally with proper defaults
htmx.config.globalViewTransitions = true;
// Initialize Alpine stores // HTMX 2.x Migration: Maintain 1.x behavior for smooth scrolling
Alpine.store('app', { htmx.config.scrollBehavior = 'smooth';
user: null,
theme: localStorage.getItem('theme') || 'system',
searchQuery: '',
notifications: []
});
Alpine.store('toast', { // HTMX 2.x Migration: Keep DELETE requests using form-encoded body (like 1.x)
toasts: [], htmx.config.methodsThatUseUrlParams = ["get"];
show(message, type = 'info', duration = 5000) {
const id = Date.now() + Math.random(); // HTMX 2.x Migration: Allow cross-domain requests (like 1.x)
const toast = { id, message, type, visible: true, progress: 100 }; htmx.config.selfRequestsOnly = false;
this.toasts.push(toast);
if (duration > 0) { // Enhanced HTMX event handling for better UX
setTimeout(() => this.hide(id), duration); 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) { // Initialize Alpine stores
const toast = this.toasts.find(t => t.id === id); Alpine.store('app', {
if (toast) { user: null,
toast.visible = false; theme: localStorage.getItem('theme') || 'system',
setTimeout(() => { searchQuery: '',
this.toasts = this.toasts.filter(t => t.id !== id); notifications: []
}, 300); });
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 %} {% block extra_js %}{% endblock %}
</body> </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> <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>
<a href="{% url 'rides:list' %}" <a href="{% url 'rides:global_ride_list' %}"
class="nav-link group relative" class="nav-link group relative"
hx-get="{% url 'rides:list' %}" hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#main-content" hx-target="#main-content"
hx-swap="innerHTML transition:true"> hx-swap="innerHTML transition:true">
<i class="fas fa-rocket mr-2 text-thrill-secondary"></i> <i class="fas fa-rocket mr-2 text-thrill-secondary"></i>
@@ -367,7 +367,7 @@
<span class="font-medium">Parks</span> <span class="font-medium">Parks</span>
</a> </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" class="flex items-center p-3 rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-700/50 transition-colors"
@click="isOpen = false"> @click="isOpen = false">
<i class="fas fa-rocket mr-3 text-thrill-secondary"></i> <i class="fas fa-rocket mr-3 text-thrill-secondary"></i>

View File

@@ -98,46 +98,67 @@
</div> </div>
<!-- Featured Parks Grid --> <!-- Featured Parks Grid -->
<div class="grid-auto-fit-lg" <div class="grid-auto-fit-lg">
hx-get="/api/parks/featured/" <!-- Static placeholder content -->
hx-trigger="revealed"
hx-swap="innerHTML">
<!-- Loading Skeletons -->
<div class="card hover-lift"> <div class="card hover-lift">
<div class="loading-skeleton aspect-video rounded-t-2xl"></div> <div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-primary to-purple-500 flex items-center justify-center">
<div class="p-6 space-y-4"> <i class="fas fa-map-marked-alt text-4xl text-white"></i>
<div class="loading-skeleton h-6 w-3/4 rounded"></div> </div>
<div class="loading-skeleton h-4 w-full rounded"></div> <div class="p-6">
<div class="loading-skeleton h-4 w-2/3 rounded"></div> <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="flex justify-between items-center">
<div class="loading-skeleton h-6 w-20 rounded-full"></div> <span class="badge badge-primary">Featured</span>
<div class="loading-skeleton h-8 w-24 rounded-lg"></div> <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>
</div> </div>
<div class="card hover-lift"> <div class="card hover-lift">
<div class="loading-skeleton aspect-video rounded-t-2xl"></div> <div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-secondary to-red-500 flex items-center justify-center">
<div class="p-6 space-y-4"> <i class="fas fa-rocket text-4xl text-white"></i>
<div class="loading-skeleton h-6 w-3/4 rounded"></div> </div>
<div class="loading-skeleton h-4 w-full rounded"></div> <div class="p-6">
<div class="loading-skeleton h-4 w-2/3 rounded"></div> <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="flex justify-between items-center">
<div class="loading-skeleton h-6 w-20 rounded-full"></div> <span class="badge badge-secondary">Popular</span>
<div class="loading-skeleton h-8 w-24 rounded-lg"></div> <button class="btn-secondary btn-sm"
hx-get="/rides/"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
Explore
</button>
</div> </div>
</div> </div>
</div> </div>
<div class="card hover-lift"> <div class="card hover-lift">
<div class="loading-skeleton aspect-video rounded-t-2xl"></div> <div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-success to-teal-500 flex items-center justify-center">
<div class="p-6 space-y-4"> <i class="fas fa-search text-4xl text-white"></i>
<div class="loading-skeleton h-6 w-3/4 rounded"></div> </div>
<div class="loading-skeleton h-4 w-full rounded"></div> <div class="p-6">
<div class="loading-skeleton h-4 w-2/3 rounded"></div> <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="flex justify-between items-center">
<div class="loading-skeleton h-6 w-20 rounded-full"></div> <span class="badge badge-success">Tools</span>
<div class="loading-skeleton h-8 w-24 rounded-lg"></div> <button class="btn-success btn-sm"
hx-get="/search/"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
Search
</button>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -145,7 +145,7 @@
{% endblock %} {% endblock %}
{% block content %} {% 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"> <div id="dashboard-content" class="relative transition-all duration-200">
{% block moderation_content %} {% block moderation_content %}
{% include "moderation/partials/dashboard_content.html" %} {% include "moderation/partials/dashboard_content.html" %}
@@ -169,7 +169,7 @@
There was a problem loading the content. Please try again. There was a problem loading the content. Please try again.
</p> </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" <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> <i class="mr-2 fas fa-sync-alt"></i>
Retry Retry
</button> </button>
@@ -180,133 +180,156 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% 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> <script>
// HTMX Configuration document.addEventListener('alpine:init', () => {
document.body.addEventListener('htmx:configRequest', function(evt) { // Moderation Dashboard Component
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}'; Alpine.data('moderationDashboard', () => ({
}); showLoading: false,
errorMessage: null,
// Loading and Error State Management init() {
const dashboard = { // HTMX Configuration
content: document.getElementById('dashboard-content'), this.setupHTMXConfig();
skeleton: document.getElementById('loading-skeleton'), this.setupEventListeners();
errorState: document.getElementById('error-state'), this.setupSearchDebouncing();
errorMessage: document.getElementById('error-message'), this.setupInfiniteScroll();
this.setupKeyboardNavigation();
},
showLoading() { setupHTMXConfig() {
this.content.setAttribute('aria-busy', 'true'); document.body.addEventListener('htmx:configRequest', (evt) => {
this.content.style.opacity = '0'; evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
this.errorState.classList.add('hidden'); });
}, },
hideLoading() { setupEventListeners() {
this.content.setAttribute('aria-busy', 'false'); // Enhanced HTMX Event Handlers
this.content.style.opacity = '1'; document.body.addEventListener('htmx:beforeRequest', (evt) => {
}, if (evt.detail.target.id === 'dashboard-content') {
this.showLoadingState();
}
});
showError(message) { document.body.addEventListener('htmx:afterOnLoad', (evt) => {
this.errorState.classList.remove('hidden'); if (evt.detail.target.id === 'dashboard-content') {
this.errorMessage.textContent = message || 'There was a problem loading the content. Please try again.'; this.hideLoadingState();
// Announce error to screen readers this.resetFocus(evt.detail.target);
this.errorMessage.setAttribute('role', 'alert'); }
} });
};
// Enhanced HTMX Event Handlers document.body.addEventListener('htmx:responseError', (evt) => {
document.body.addEventListener('htmx:beforeRequest', function(evt) { if (evt.detail.target.id === 'dashboard-content') {
if (evt.detail.target.id === 'dashboard-content') { this.showErrorState(evt.detail.error);
dashboard.showLoading(); }
} });
}); },
document.body.addEventListener('htmx:afterOnLoad', function(evt) { showLoadingState() {
if (evt.detail.target.id === 'dashboard-content') { const content = this.$el.querySelector('#dashboard-content');
dashboard.hideLoading(); if (content) {
// Reset focus for accessibility content.setAttribute('aria-busy', 'true');
const firstFocusable = evt.detail.target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); content.style.opacity = '0';
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;
} }
}); 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> </script>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,6 @@
{% load static %} {% load static %}
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
<style> <style>
/* Ensure map container and its elements stay below other UI elements */ /* Ensure map container and its elements stay below other UI elements */
.leaflet-pane, .leaflet-pane,
@@ -19,38 +20,132 @@
} }
</style> </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 #} {# Search Form #}
<div class="relative mb-4"> <div class="relative mb-4">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Search Location Search Location
</label> </label>
<input type="text" <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" 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..." placeholder="Search for a location..."
autocomplete="off" autocomplete="off"
style="z-index: 10;"> 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;" 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>
</div> </div>
{# Map Container #} {# Map Container #}
<div class="relative mb-4" style="z-index: 1;"> <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> </div>
{# Location Form Fields #} {# 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> <div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Street Address Street Address
</label> </label>
<input type="text" <input type="text"
name="street_address" 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" 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:'' }}"> value="{{ form.street_address.value|default:'' }}">
</div> </div>
@@ -60,7 +155,7 @@
</label> </label>
<input type="text" <input type="text"
name="city" 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" 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:'' }}"> value="{{ form.city.value|default:'' }}">
</div> </div>
@@ -70,7 +165,7 @@
</label> </label>
<input type="text" <input type="text"
name="state" 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" 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:'' }}"> value="{{ form.state.value|default:'' }}">
</div> </div>
@@ -80,7 +175,7 @@
</label> </label>
<input type="text" <input type="text"
name="country" 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" 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:'' }}"> value="{{ form.country.value|default:'' }}">
</div> </div>
@@ -90,7 +185,7 @@
</label> </label>
<input type="text" <input type="text"
name="postal_code" 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" 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:'' }}"> value="{{ form.postal_code.value|default:'' }}">
</div> </div>
@@ -98,306 +193,19 @@
{# Hidden Coordinate Fields #} {# Hidden Coordinate Fields #}
<div class="hidden"> <div class="hidden">
<input type="hidden" name="latitude" id="latitude" value="{{ form.latitude.value|default:'' }}"> <input type="hidden" name="latitude" x-ref="latitude" value="{{ form.latitude.value|default:'' }}">
<input type="hidden" name="longitude" id="longitude" value="{{ form.longitude.value|default:'' }}"> <input type="hidden" name="longitude" x-ref="longitude" value="{{ form.longitude.value|default:'' }}">
</div> </div>
</div> </div>
<script> <!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
document.addEventListener('DOMContentLoaded', function() { <div x-data="{
let map = null; init() {
let marker = null; // Only essential HTMX error handling as shown in Context7 docs
const searchInput = document.getElementById('locationSearch'); this.$el.addEventListener('htmx:responseError', (evt) => {
const searchResults = document.getElementById('searchResults'); if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
let searchTimeout; console.error('HTMX Error:', evt.detail.xhr.status);
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.');
} }
}); });
} }
}"></div>
// 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>

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 %} {% if parks %}
{% for park in parks %} {% for park in parks %}
<button type="button" <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" 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 }} {{ park.name }}
</button> </button>
{% endfor %} {% endfor %}
@@ -17,11 +40,3 @@
</div> </div>
{% endif %} {% endif %}
</div> </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 %} {% endblock %}
{% block content %} {% 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 --> <!-- Header -->
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center"> <div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
<div> <div>
@@ -167,7 +241,7 @@
</div> </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"> <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>
</div> </div>
@@ -175,61 +249,80 @@
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
<button id="clear-trip" <button class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
@click="clearTrip()"> @click="clearTrip()">
<i class="mr-1 fas fa-trash"></i>Clear All <i class="mr-1 fas fa-trash"></i>Clear All
</button> </button>
</div> </div>
<div id="trip-parks" class="space-y-2 min-h-20"> <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"> <template x-if="tripParks.length === 0">
<i class="fas fa-route text-3xl mb-3"></i> <div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>Add parks to start planning your trip</p> <i class="fas fa-route text-3xl mb-3"></i>
<p class="text-sm mt-1">Search above or click parks on the map</p> <p>Add parks to start planning your trip</p>
</div> <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>
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
<button id="optimize-route" <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"
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()"
@click="optimizeRoute()" :disabled="tripParks.length < 2"> :disabled="tripParks.length < 2">
<i class="mr-2 fas fa-route"></i>Optimize Route <i class="mr-2 fas fa-route"></i>Optimize Route
</button> </button>
<button id="calculate-route" <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"
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()"
@click="calculateRoute()" :disabled="tripParks.length < 2"> :disabled="tripParks.length < 2">
<i class="mr-2 fas fa-map"></i>Calculate Route <i class="mr-2 fas fa-map"></i>Calculate Route
</button> </button>
</div> </div>
</div> </div>
<!-- Trip Summary --> <!-- 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> <h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
<div class="trip-stats"> <div class="trip-stats">
<div class="trip-stat"> <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 class="trip-stat-label">Total Miles</div>
</div> </div>
<div class="trip-stat"> <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 class="trip-stat-label">Drive Time</div>
</div> </div>
<div class="trip-stat"> <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 class="trip-stat-label">Parks</div>
</div> </div>
<div class="trip-stat"> <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 class="trip-stat-label">Total Rides</div>
</div> </div>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<button id="save-trip" <button class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
@click="saveTrip()"> @click="saveTrip()">
<i class="mr-2 fas fa-save"></i>Save Trip <i class="mr-2 fas fa-save"></i>Save Trip
</button> </button>
@@ -243,26 +336,32 @@
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
<div class="flex gap-2"> <div class="flex gap-2">
<button id="fit-route" <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"
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/"
@click="fitRoute()"> hx-vals='{"parks": "{{ tripParks|join:"," }}"}'
hx-target="#map-container"
hx-swap="none">
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route <i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
</button> </button>
<button id="toggle-parks" <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"
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"
@click="toggleAllParks()"> hx-post="/maps/toggle-parks/"
<i class="mr-1 fas fa-eye"></i><span x-text="showAllParks ? 'Hide Parks' : 'Show All Parks'">Show All Parks</span> 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> </button>
</div> </div>
</div> </div>
<div id="map-container" class="map-container"></div> <div id="map-container" class="map-container">
<!-- Map will be loaded via HTMX -->
<!-- Map Loading Indicator --> <div class="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-800 rounded-lg">
<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="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>
<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>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -286,7 +385,7 @@
hx-get="{% url 'parks:htmx_saved_trips' %}" hx-get="{% url 'parks:htmx_saved_trips' %}"
hx-trigger="load" hx-trigger="load"
hx-indicator="#trips-loading"> hx-indicator="#trips-loading">
<!-- Saved trips will be loaded here --> <!-- Saved trips will be loaded here via HTMX -->
</div> </div>
<div id="trips-loading" class="htmx-indicator text-center py-4"> <div id="trips-loading" class="htmx-indicator text-center py-4">
@@ -299,255 +398,19 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<!-- Leaflet JS --> <!-- External libraries for map functionality only -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <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> <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> <!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
document.addEventListener('alpine:init', () => { <div x-data="{
Alpine.data('tripPlanner', () => ({ init() {
map: null, // Only essential HTMX error handling as shown in Context7 docs
tripParks: [], this.$el.addEventListener('htmx:responseError', (evt) => {
allParks: [], if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
showAllParks: false, console.error('HTMX Error:', evt.detail.xhr.status);
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();
} }
}, });
}
setupSortable() { }"></div>
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>
{% endblock %} {% endblock %}

View File

@@ -173,20 +173,37 @@
{% endif %} {% endif %}
<!-- Search Suggestions --> <!-- 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> <span class="text-xs text-muted-foreground">Try:</span>
<button class="text-xs text-primary hover:text-primary/80 transition-colors" <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 Disney
</button> </button>
<span class="text-xs text-muted-foreground"></span> <span class="text-xs text-muted-foreground"></span>
<button class="text-xs text-primary hover:text-primary/80 transition-colors" <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 Roller Coaster
</button> </button>
<span class="text-xs text-muted-foreground"></span> <span class="text-xs text-muted-foreground"></span>
<button class="text-xs text-primary hover:text-primary/80 transition-colors" <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 Cedar Point
</button> </button>
</div> </div>

View File

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

View File

@@ -1,7 +1,45 @@
{% load static %} {% load static %}
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
<!-- Advanced Ride Filters Sidebar --> <!-- 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 --> <!-- 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="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"> <div class="flex items-center justify-between">
@@ -42,7 +80,7 @@
{{ filter_name }}: {{ filter_value }} {{ filter_name }}: {{ filter_value }}
<button type="button" <button type="button"
class="ml-1 h-3 w-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" 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> <i class="fas fa-times text-xs"></i>
</button> </button>
</span> </span>
@@ -67,16 +105,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700"> <div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button" <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" 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"> <div class="flex items-center justify-between">
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-search mr-2 text-gray-500"></i> <i class="fas fa-search mr-2 text-gray-500"></i>
Search Search
</span> </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> </div>
</button> </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.label_tag }}
{{ filter_form.search_text }} {{ filter_form.search_text }}
@@ -93,16 +132,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700"> <div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button" <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" 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"> <div class="flex items-center justify-between">
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-info-circle mr-2 text-gray-500"></i> <i class="fas fa-info-circle mr-2 text-gray-500"></i>
Basic Info Basic Info
</span> </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> </div>
</button> </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 --> <!-- Categories -->
<div> <div>
{{ filter_form.categories.label_tag }} {{ filter_form.categories.label_tag }}
@@ -127,16 +167,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700"> <div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button" <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" 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"> <div class="flex items-center justify-between">
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-calendar mr-2 text-gray-500"></i> <i class="fas fa-calendar mr-2 text-gray-500"></i>
Date Ranges Date Ranges
</span> </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> </div>
</button> </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 --> <!-- Opening Date Range -->
<div> <div>
{{ filter_form.opening_date_range.label_tag }} {{ filter_form.opening_date_range.label_tag }}
@@ -155,16 +196,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700"> <div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button" <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" 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"> <div class="flex items-center justify-between">
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-ruler-vertical mr-2 text-gray-500"></i> <i class="fas fa-ruler-vertical mr-2 text-gray-500"></i>
Height & Safety Height & Safety
</span> </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> </div>
</button> </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 --> <!-- Height Requirements -->
<div> <div>
{{ filter_form.height_requirements.label_tag }} {{ filter_form.height_requirements.label_tag }}
@@ -189,16 +231,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700"> <div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button" <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" 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"> <div class="flex items-center justify-between">
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-tachometer-alt mr-2 text-gray-500"></i> <i class="fas fa-tachometer-alt mr-2 text-gray-500"></i>
Performance Performance
</span> </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> </div>
</button> </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 --> <!-- Speed Range -->
<div> <div>
{{ filter_form.speed_range.label_tag }} {{ filter_form.speed_range.label_tag }}
@@ -229,16 +272,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700"> <div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button" <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" 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"> <div class="flex items-center justify-between">
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-sitemap mr-2 text-gray-500"></i> <i class="fas fa-sitemap mr-2 text-gray-500"></i>
Companies & Models Companies & Models
</span> </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> </div>
</button> </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 --> <!-- Manufacturers -->
<div> <div>
{{ filter_form.manufacturers.label_tag }} {{ filter_form.manufacturers.label_tag }}
@@ -263,16 +307,17 @@
<div class="filter-section border-b border-gray-200 dark:border-gray-700"> <div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button" <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" 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"> <div class="flex items-center justify-between">
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-mountain mr-2 text-gray-500"></i> <i class="fas fa-mountain mr-2 text-gray-500"></i>
Roller Coaster Details Roller Coaster Details
</span> </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> </div>
</button> </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 --> <!-- Track Type -->
<div> <div>
{{ filter_form.track_types.label_tag }} {{ filter_form.track_types.label_tag }}
@@ -324,16 +369,17 @@
<div class="filter-section"> <div class="filter-section">
<button type="button" <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" 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"> <div class="flex items-center justify-between">
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-sort mr-2 text-gray-500"></i> <i class="fas fa-sort mr-2 text-gray-500"></i>
Sorting Sorting
</span> </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> </div>
</button> </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 --> <!-- Sort By -->
<div> <div>
{{ filter_form.sort_by.label_tag }} {{ filter_form.sort_by.label_tag }}
@@ -350,116 +396,14 @@
</form> </form>
</div> </div>
<!-- Filter JavaScript --> <!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
<script> <div x-data="{
document.addEventListener('DOMContentLoaded', function() { init() {
// Initialize collapsible sections // Only essential HTMX error handling as shown in Context7 docs
initializeFilterSections(); this.$el.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
// Initialize filter form handlers console.error('HTMX Error:', evt.detail.xhr.status);
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)';
} }
}); }"></div>
}
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>

View File

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

View File

@@ -11,25 +11,27 @@ document.addEventListener('alpine:init', () => {
}, },
selectManufacturer(id, name) { selectManufacturer(id, name) {
const manufacturerInput = document.getElementById('id_manufacturer'); // Use AlpineJS $el to scope queries within component
const manufacturerSearch = document.getElementById('id_manufacturer_search'); const manufacturerInput = this.$el.querySelector('#id_manufacturer');
const manufacturerResults = document.getElementById('manufacturer-search-results'); const manufacturerSearch = this.$el.querySelector('#id_manufacturer_search');
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
if (manufacturerInput) manufacturerInput.value = id; if (manufacturerInput) manufacturerInput.value = id;
if (manufacturerSearch) manufacturerSearch.value = name; if (manufacturerSearch) manufacturerSearch.value = name;
if (manufacturerResults) manufacturerResults.innerHTML = ''; if (manufacturerResults) manufacturerResults.innerHTML = '';
// Update ride model search to include manufacturer // Update ride model search to include manufacturer
const rideModelSearch = document.getElementById('id_ride_model_search'); const rideModelSearch = this.$el.querySelector('#id_ride_model_search');
if (rideModelSearch) { if (rideModelSearch) {
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]'); rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
} }
}, },
selectDesigner(id, name) { selectDesigner(id, name) {
const designerInput = document.getElementById('id_designer'); // Use AlpineJS $el to scope queries within component
const designerSearch = document.getElementById('id_designer_search'); const designerInput = this.$el.querySelector('#id_designer');
const designerResults = document.getElementById('designer-search-results'); const designerSearch = this.$el.querySelector('#id_designer_search');
const designerResults = this.$el.querySelector('#designer-search-results');
if (designerInput) designerInput.value = id; if (designerInput) designerInput.value = id;
if (designerSearch) designerSearch.value = name; if (designerSearch) designerSearch.value = name;
@@ -37,9 +39,10 @@ document.addEventListener('alpine:init', () => {
}, },
selectRideModel(id, name) { selectRideModel(id, name) {
const rideModelInput = document.getElementById('id_ride_model'); // Use AlpineJS $el to scope queries within component
const rideModelSearch = document.getElementById('id_ride_model_search'); const rideModelInput = this.$el.querySelector('#id_ride_model');
const rideModelResults = document.getElementById('ride-model-search-results'); const rideModelSearch = this.$el.querySelector('#id_ride_model_search');
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
if (rideModelInput) rideModelInput.value = id; if (rideModelInput) rideModelInput.value = id;
if (rideModelSearch) rideModelSearch.value = name; if (rideModelSearch) rideModelSearch.value = name;
@@ -47,9 +50,10 @@ document.addEventListener('alpine:init', () => {
}, },
clearAllSearchResults() { clearAllSearchResults() {
const manufacturerResults = document.getElementById('manufacturer-search-results'); // Use AlpineJS $el to scope queries within component
const designerResults = document.getElementById('designer-search-results'); const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
const rideModelResults = document.getElementById('ride-model-search-results'); const designerResults = this.$el.querySelector('#designer-search-results');
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
if (manufacturerResults) manufacturerResults.innerHTML = ''; if (manufacturerResults) manufacturerResults.innerHTML = '';
if (designerResults) designerResults.innerHTML = ''; if (designerResults) designerResults.innerHTML = '';
@@ -57,17 +61,20 @@ document.addEventListener('alpine:init', () => {
}, },
clearManufacturerResults() { clearManufacturerResults() {
const manufacturerResults = document.getElementById('manufacturer-search-results'); // Use AlpineJS $el to scope queries within component
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
if (manufacturerResults) manufacturerResults.innerHTML = ''; if (manufacturerResults) manufacturerResults.innerHTML = '';
}, },
clearDesignerResults() { clearDesignerResults() {
const designerResults = document.getElementById('designer-search-results'); // Use AlpineJS $el to scope queries within component
const designerResults = this.$el.querySelector('#designer-search-results');
if (designerResults) designerResults.innerHTML = ''; if (designerResults) designerResults.innerHTML = '';
}, },
clearRideModelResults() { clearRideModelResults() {
const rideModelResults = document.getElementById('ride-model-search-results'); // Use AlpineJS $el to scope queries within component
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
if (rideModelResults) rideModelResults.innerHTML = ''; if (rideModelResults) rideModelResults.innerHTML = '';
} }
})); }));

View File

@@ -1,53 +1,103 @@
{% load static %} {% load static %}
<form method="post" <script>
class="space-y-6" document.addEventListener('alpine:init', () => {
x-data="{ Alpine.data('rideModelForm', () => ({
submitting: false, submitting: false,
manufacturerSearchTerm: '', manufacturerSearchTerm: '',
setManufacturerModal(value, term = '') {
const parentForm = document.querySelector('[x-data]'); init() {
if (parentForm) { // Listen for HTMX events on this form
const parentData = Alpine.$data(parentForm); this.$el.addEventListener('htmx:afterRequest', (event) => {
if (parentData && parentData.setManufacturerModal) { if (event.detail.pathInfo.requestPath === '/rides/models/create/') {
parentData.setManufacturerModal(value, term); this.handleResponse(event);
} }
} });
}
}" // Initialize form with any pre-filled values
@submit.prevent=" this.initializeForm();
if (!submitting) { },
submitting = true;
const formData = new FormData($event.target); 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/', { htmx.ajax('POST', '/rides/models/create/', {
values: Object.fromEntries(formData), values: Object.fromEntries(formData),
headers: { headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value 'X-CSRFToken': csrfToken
} },
target: this.$el,
swap: 'none'
}); });
},
// Handle HTMX response using event listeners handleResponse(event) {
document.addEventListener('htmx:afterRequest', function handleResponse(event) { this.submitting = false;
if (event.detail.pathInfo.requestPath === '/rides/models/create/') {
document.removeEventListener('htmx:afterRequest', handleResponse);
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) { if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
const data = JSON.parse(event.detail.xhr.response); const data = JSON.parse(event.detail.xhr.response);
if (typeof selectRideModel === 'function') {
selectRideModel(data.id, data.name); // Dispatch event with ride model data for parent components
} this.$dispatch('ride-model-created', {
const parentForm = document.querySelector('[x-data]'); id: data.id,
if (parentForm) { name: data.name
const parentData = Alpine.$data(parentForm); });
if (parentData && parentData.setRideModelModal) {
parentData.setRideModelModal(false); // Close modal if in modal context
} this.$dispatch('close-ride-model-modal');
} } else {
} // Handle error case
submitting = false; 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 %} {% csrf_token %}
<div id="ride-model-notification"></div> <div id="ride-model-notification"></div>
@@ -175,49 +225,3 @@
</button> </button>
</div> </div>
</form> </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,365 +1,122 @@
<script> <!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
document.addEventListener('alpine:init', () => { <div x-data="{
Alpine.data('rideSearch', () => ({ searchQuery: new URLSearchParams(window.location.search).get('search') || '',
init() { showSuggestions: false,
// Initialize from URL params selectedIndex: -1,
const urlParams = new URLSearchParams(window.location.search);
this.searchQuery = urlParams.get('search') || '';
// Bind to form reset init() {
document.querySelector('form').addEventListener('reset', () => { // Watch for URL changes
this.searchQuery = ''; 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.showSuggestions = false;
this.selectedIndex = -1; this.selectedIndex = -1;
this.cleanup(); this.$refs.searchInput.blur();
}); break;
case 'Enter':
// Handle clicks outside suggestions if (e.target.tagName === 'BUTTON') {
document.addEventListener('click', (e) => { e.preventDefault();
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) { this.selectSuggestion(e.target.dataset.text);
this.showSuggestions = false;
} }
}); break;
case 'Tab':
// 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) {
this.showSuggestions = false; this.showSuggestions = false;
return; break;
}
// 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;
}
} }
})); }
}); }"
}, @click.outside="showSuggestions = false">
performCleanup() { <!-- Search Input with HTMX -->
// Remove all bound event listeners <input
this.boundHandlers.forEach(this.removeEventHandler.bind(this)); x-ref="searchInput"
this.boundHandlers.clear(); 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"
/>
// Cancel any pending requests <!-- Suggestions Container -->
if (this.currentRequest) { <div
this.currentRequest.abort(); x-show="showSuggestions"
this.currentRequest = null; 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>
// Clear any pending timeouts <!-- Form Reference for HTMX -->
if (this.suggestionTimeout) { <form x-ref="searchForm" style="display: none;">
clearTimeout(this.suggestionTimeout); <!-- Hidden form for HTMX reference -->
} </form>
}, </div>
removeEventHandler(handler, event) {
if (event === 'popstate') {
window.removeEventListener(event, handler);
} else {
document.body.removeEventListener(event, handler);
}
}
}));
});
</script>
<!-- HTMX Loading Indicator Styles --> <!-- HTMX Loading Indicator Styles -->
<style> <style>
@@ -368,10 +125,9 @@ document.addEventListener('alpine:init', () => {
transition: opacity 200ms ease-in; transition: opacity 200ms ease-in;
} }
.htmx-request .htmx-indicator { .htmx-request .htmx-indicator {
opacity: 1 opacity: 1;
} }
/* Enhanced Loading Indicator */
.loading-indicator { .loading-indicator {
position: fixed; position: fixed;
bottom: 1rem; bottom: 1rem;
@@ -396,60 +152,14 @@ document.addEventListener('alpine:init', () => {
} }
</style> </style>
<script> <!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
// Initialize request timeout management <div x-data="{
const timeouts = new Map(); init() {
// Only essential HTMX error handling as shown in Context7 docs
// Handle request start this.$el.addEventListener('htmx:responseError', (evt) => {
document.addEventListener('htmx:beforeRequest', function(evt) { if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
const timestamp = document.querySelector('.loading-timestamp'); console.error('HTMX Error:', evt.detail.xhr.status);
if (timestamp) { }
timestamp.textContent = new Date().toLocaleTimeString(); });
} }
}"></div>
// 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>

View File

@@ -15,26 +15,7 @@
{% endif %} {% endif %}
</h1> </h1>
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="{ <form method="post" enctype="multipart/form-data" class="space-y-6" x-data="rideFormData()">
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);
}
}">
{% csrf_token %} {% csrf_token %}
{% if not park %} {% if not park %}
@@ -242,4 +223,41 @@
</form> </form>
</div> </div>
</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 %} {% 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 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 %} {% block content %}
<!-- Advanced Search Page --> <!-- 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"> <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 --> <!-- 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"> <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 --> <!-- Quick Search Bar -->
<div class="relative max-w-2xl mx-auto"> <div class="relative max-w-2xl mx-auto">
<input type="text" <input type="text"
id="quick-search"
placeholder="Quick search: parks, rides, locations..." 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" 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-get="/search/quick/"
hx-trigger="keyup changed delay:300ms" 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"> <div class="absolute left-6 top-1/2 transform -translate-y-1/2">
<i class="fas fa-search text-2xl text-thrill-primary"></i> <i class="fas fa-search text-2xl text-thrill-primary"></i>
</div> </div>
@@ -55,7 +69,7 @@
Filters Filters
</h2> </h2>
<form id="advanced-search-form" <form id="filter-form"
hx-get="/search/results/" hx-get="/search/results/"
hx-target="#search-results" hx-target="#search-results"
hx-trigger="change, submit" hx-trigger="change, submit"
@@ -66,18 +80,30 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">Search For</label> <label class="form-label">Search For</label>
<div class="grid grid-cols-2 gap-2"> <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"> <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"> :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-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> </div>
<i class="fas fa-map-marked-alt mr-2 text-thrill-primary"></i> <i class="fas fa-map-marked-alt mr-2 text-thrill-primary"></i>
Parks Parks
</label> </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"> <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"> :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-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> </div>
<i class="fas fa-rocket mr-2 text-thrill-secondary"></i> <i class="fas fa-rocket mr-2 text-thrill-secondary"></i>
Rides Rides
@@ -109,7 +135,7 @@
</div> </div>
<!-- Park-Specific Filters --> <!-- 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"> <div class="form-group">
<label class="form-label">Park Type</label> <label class="form-label">Park Type</label>
<select name="park_type" class="form-select"> <select name="park_type" class="form-select">
@@ -125,62 +151,56 @@
<label class="form-label">Park Status</label> <label class="form-label">Park Status</label>
<div class="space-y-2"> <div class="space-y-2">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" name="status" value="OPERATING" checked class="sr-only"> <input type="checkbox" name="status" value="OPERATING" checked class="form-checkbox">
<div class="checkbox-custom mr-3"></div> <span class="badge-operating ml-2">Operating</span>
<span class="badge-operating">Operating</span>
</label> </label>
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" name="status" value="CONSTRUCTION" class="sr-only"> <input type="checkbox" name="status" value="CONSTRUCTION" class="form-checkbox">
<div class="checkbox-custom mr-3"></div> <span class="badge-construction ml-2">Under Construction</span>
<span class="badge-construction">Under Construction</span>
</label> </label>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Minimum Rides</label> <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"> <div class="flex justify-between text-sm text-neutral-500 mt-1">
<span>0</span> <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> <span>100+</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Ride-Specific Filters --> <!-- 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"> <div class="form-group">
<label class="form-label">Thrill Level</label> <label class="form-label">Thrill Level</label>
<div class="space-y-2"> <div class="space-y-2">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" name="thrill_level" value="MILD" class="sr-only"> <input type="checkbox" name="thrill_level" value="MILD" class="form-checkbox">
<div class="checkbox-custom mr-3"></div> <span class="badge bg-green-500/10 text-green-600 border-green-500/20 ml-2">
<span class="badge bg-green-500/10 text-green-600 border-green-500/20">
<i class="fas fa-leaf mr-1"></i> <i class="fas fa-leaf mr-1"></i>
Family Friendly Family Friendly
</span> </span>
</label> </label>
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" name="thrill_level" value="MODERATE" class="sr-only"> <input type="checkbox" name="thrill_level" value="MODERATE" class="form-checkbox">
<div class="checkbox-custom mr-3"></div> <span class="badge bg-yellow-500/10 text-yellow-600 border-yellow-500/20 ml-2">
<span class="badge bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
<i class="fas fa-star mr-1"></i> <i class="fas fa-star mr-1"></i>
Moderate Moderate
</span> </span>
</label> </label>
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" name="thrill_level" value="HIGH" class="sr-only"> <input type="checkbox" name="thrill_level" value="HIGH" class="form-checkbox">
<div class="checkbox-custom mr-3"></div> <span class="badge bg-orange-500/10 text-orange-600 border-orange-500/20 ml-2">
<span class="badge bg-orange-500/10 text-orange-600 border-orange-500/20">
<i class="fas fa-bolt mr-1"></i> <i class="fas fa-bolt mr-1"></i>
High Thrill High Thrill
</span> </span>
</label> </label>
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" name="thrill_level" value="EXTREME" class="sr-only"> <input type="checkbox" name="thrill_level" value="EXTREME" class="form-checkbox">
<div class="checkbox-custom mr-3"></div> <span class="badge bg-red-500/10 text-red-600 border-red-500/20 ml-2">
<span class="badge bg-red-500/10 text-red-600 border-red-500/20">
<i class="fas fa-fire mr-1"></i> <i class="fas fa-fire mr-1"></i>
Extreme Extreme
</span> </span>
@@ -202,20 +222,20 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">Minimum Height (ft)</label> <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"> <div class="flex justify-between text-sm text-neutral-500 mt-1">
<span>0ft</span> <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> <span>500ft+</span>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Minimum Speed (mph)</label> <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"> <div class="flex justify-between text-sm text-neutral-500 mt-1">
<span>0mph</span> <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> <span>150mph+</span>
</div> </div>
</div> </div>
@@ -236,9 +256,11 @@
</div> </div>
<!-- Clear Filters --> <!-- Clear Filters -->
<button type="button" <button type="reset"
id="clear-filters" class="btn-ghost w-full"
class="btn-ghost w-full"> hx-get="/search/results/"
hx-target="#search-results"
hx-swap="innerHTML">
<i class="fas fa-times mr-2"></i> <i class="fas fa-times mr-2"></i>
Clear All Filters Clear All Filters
</button> </button>
@@ -252,24 +274,30 @@
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h2 class="text-2xl font-bold">Search Results</h2> <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 Use filters to find your perfect adventure
</p> </p>
</div> </div>
<!-- View Toggle --> <!-- 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"> <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> <i class="fas fa-th-large"></i>
</button> </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> <i class="fas fa-list"></i>
</button> </button>
</div> </div>
</div> </div>
<!-- Search Results Container --> <!-- 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 --> <!-- Initial State -->
<div class="text-center py-16"> <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"> <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> </div>
<!-- Load More Button --> <!-- 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" <button class="btn-secondary btn-lg"
hx-get="/search/results/" hx-get="/search/results/"
hx-target="#search-results" hx-target="#search-results"
@@ -298,51 +326,8 @@
</section> </section>
</div> </div>
<!-- AlpineJS Advanced Search Component (HTMX + AlpineJS Only) --> <!-- Custom CSS for enhanced styling -->
<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 -->
<style> <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 { .grid-view .search-results-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));

View File

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

View File

@@ -54,7 +54,7 @@
} }
</style> </style>
</head> </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"> <div class="max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-8 text-center">UI Component Comparison Test</h1> <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> <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,72 +582,94 @@
</div> </div>
</div> </div>
<!-- Alpine.js -->
<script src="{% static 'js/alpine.min.js' %}" defer></script>
<script> <script>
// Function to normalize HTML for comparison document.addEventListener('alpine:init', () => {
function normalizeHTML(html) { // Component Test Suite Component
return html Alpine.data('componentTestSuite', () => ({
.replace(/\s+/g, ' ') init() {
.replace(/> </g, '><') // Extract HTML after Alpine.js initializes
.trim(); this.$nextTick(() => {
} setTimeout(() => this.extractComponentHTML(), 100);
this.addCompareButton();
});
},
// Function to extract HTML from all component containers // Function to normalize HTML for comparison
function extractComponentHTML() { normalizeHTML(html) {
const containers = document.querySelectorAll('.button-container'); return html
const includeHTMLs = []; .replace(/\s+/g, ' ')
const cottonHTMLs = []; .replace(/> </g, '><')
let componentIndex = 1; .trim();
},
containers.forEach((container, index) => { // Function to extract HTML from all component containers
const label = container.getAttribute('data-label'); extractComponentHTML() {
// Look for button, input, or div (card) elements const containers = this.$el.querySelectorAll('.button-container');
const element = container.querySelector('button') || const includeHTMLs = [];
container.querySelector('input') || const cottonHTMLs = [];
container.querySelector('div.rounded-lg'); let componentIndex = 1;
if (element && label) { containers.forEach((container, index) => {
const html = element.outerHTML; const label = container.getAttribute('data-label');
const normalized = normalizeHTML(html); // Look for button, input, or div (card) elements
const element = container.querySelector('button') ||
container.querySelector('input') ||
container.querySelector('div.rounded-lg');
if (label === 'Include Version') { if (element && label) {
includeHTMLs.push(`<!-- Component ${componentIndex} -->\n${normalized}\n`); const html = element.outerHTML;
} else if (label === 'Cotton Version') { const normalized = this.normalizeHTML(html);
cottonHTMLs.push(`<!-- Component ${componentIndex} -->\n${normalized}\n`);
componentIndex++; 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 (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> </script>
</body> </body>