Compare commits

..

23 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Get all alert elements
const alerts = document.querySelectorAll('.alert');
// For each alert
alerts.forEach(alert => {
// After 5 seconds
setTimeout(() => {
// Add slideOut animation
alert.style.animation = 'slideOut 0.5s ease-out forwards';
// Remove the alert after animation completes
setTimeout(() => {
alert.remove();
}, 500);
}, 5000);
});
});

View File

@@ -1,746 +0,0 @@
/**
* Alpine.js Components for ThrillWiki
* Enhanced components matching React frontend functionality
*/
// Flag to prevent duplicate component registration
let componentsRegistered = false;
// Debug logging to see what's happening
console.log('Alpine components script is loading...');
// Try multiple approaches to ensure components register
function registerComponents() {
// Prevent duplicate registration
if (componentsRegistered) {
return;
}
if (typeof Alpine === 'undefined') {
console.warn('Alpine.js not available yet, registration will retry');
return;
}
console.log('Registering Alpine.js components...');
// Mark as registered at the start to prevent race conditions
componentsRegistered = true;
// Theme Toggle Component
Alpine.data('themeToggle', () => ({
theme: localStorage.getItem('theme') || 'system',
init() {
this.updateTheme();
// Watch for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.theme === 'system') {
this.updateTheme();
}
});
},
toggleTheme() {
const themes = ['light', 'dark', 'system'];
const currentIndex = themes.indexOf(this.theme);
this.theme = themes[(currentIndex + 1) % themes.length];
localStorage.setItem('theme', this.theme);
this.updateTheme();
},
updateTheme() {
const root = document.documentElement;
if (this.theme === 'dark' ||
(this.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}
}));
// Search Component
Alpine.data('searchComponent', () => ({
query: '',
results: [],
loading: false,
showResults: false,
async search() {
if (this.query.length < 2) {
this.results = [];
this.showResults = false;
return;
}
this.loading = true;
try {
// Use the same search endpoint as HTMX in the template
const response = await fetch(`/search/parks/?q=${encodeURIComponent(this.query)}`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.ok) {
// Try to parse as JSON first, fallback to extracting from HTML
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
this.results = data.results || [];
} else {
// Parse HTML response to extract search results
const html = await response.text();
this.results = this.parseSearchResults(html);
}
this.showResults = this.results.length > 0;
} else {
this.results = [];
this.showResults = false;
}
} catch (error) {
console.error('Search error:', error);
this.results = [];
this.showResults = false;
} finally {
this.loading = false;
}
},
parseSearchResults(html) {
// Helper method to extract search results from HTML response
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const results = [];
// Look for search result items in the HTML
const resultElements = doc.querySelectorAll('[data-search-result], .search-result-item, .park-item');
resultElements.forEach(element => {
const title = element.querySelector('h3, .title, [data-title]')?.textContent?.trim();
const url = element.querySelector('a')?.getAttribute('href');
const description = element.querySelector('.description, .excerpt, p')?.textContent?.trim();
if (title && url) {
results.push({
title,
url,
description: description || ''
});
}
});
return results.slice(0, 10); // Limit to 10 results
} catch (error) {
console.error('Error parsing search results:', error);
return [];
}
},
selectResult(result) {
window.location.href = result.url;
this.showResults = false;
this.query = '';
},
clearSearch() {
this.query = '';
this.results = [];
this.showResults = false;
}
}));
// Browse Menu Component
Alpine.data('browseMenu', () => ({
open: false,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
}
}));
// Mobile Menu Component
Alpine.data('mobileMenu', () => ({
open: false,
toggle() {
this.open = !this.open;
// Prevent body scroll when menu is open
if (this.open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
},
close() {
this.open = false;
document.body.style.overflow = '';
}
}));
// User Menu Component
Alpine.data('userMenu', () => ({
open: false,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
}
}));
// Modal Component
Alpine.data('modal', (initialOpen = false) => ({
open: initialOpen,
show() {
this.open = true;
document.body.style.overflow = 'hidden';
},
hide() {
this.open = false;
document.body.style.overflow = '';
},
toggle() {
if (this.open) {
this.hide();
} else {
this.show();
}
}
}));
// Dropdown Component
Alpine.data('dropdown', (initialOpen = false) => ({
open: initialOpen,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
},
show() {
this.open = true;
}
}));
// Tabs Component
Alpine.data('tabs', (defaultTab = 0) => ({
activeTab: defaultTab,
setTab(index) {
this.activeTab = index;
},
isActive(index) {
return this.activeTab === index;
}
}));
// Accordion Component
Alpine.data('accordion', (allowMultiple = false) => ({
openItems: [],
toggle(index) {
if (this.isOpen(index)) {
this.openItems = this.openItems.filter(item => item !== index);
} else {
if (allowMultiple) {
this.openItems.push(index);
} else {
this.openItems = [index];
}
}
},
isOpen(index) {
return this.openItems.includes(index);
},
open(index) {
if (!this.isOpen(index)) {
if (allowMultiple) {
this.openItems.push(index);
} else {
this.openItems = [index];
}
}
},
close(index) {
this.openItems = this.openItems.filter(item => item !== index);
}
}));
// Form Component with Validation
Alpine.data('form', (initialData = {}) => ({
data: initialData,
errors: {},
loading: false,
setField(field, value) {
this.data[field] = value;
// Clear error when user starts typing
if (this.errors[field]) {
delete this.errors[field];
}
},
setError(field, message) {
this.errors[field] = message;
},
clearErrors() {
this.errors = {};
},
hasError(field) {
return !!this.errors[field];
},
getError(field) {
return this.errors[field] || '';
},
async submit(url, options = {}) {
this.loading = true;
this.clearErrors();
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
...options.headers
},
body: JSON.stringify(this.data),
...options
});
const result = await response.json();
if (!response.ok) {
if (result.errors) {
this.errors = result.errors;
}
throw new Error(result.message || 'Form submission failed');
}
return result;
} catch (error) {
console.error('Form submission error:', error);
throw error;
} finally {
this.loading = false;
}
}
}));
// Pagination Component
Alpine.data('pagination', (initialPage = 1, totalPages = 1) => ({
currentPage: initialPage,
totalPages: totalPages,
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
}
},
nextPage() {
this.goToPage(this.currentPage + 1);
},
prevPage() {
this.goToPage(this.currentPage - 1);
},
hasNext() {
return this.currentPage < this.totalPages;
},
hasPrev() {
return this.currentPage > 1;
},
getPages() {
const pages = [];
const start = Math.max(1, this.currentPage - 2);
const end = Math.min(this.totalPages, this.currentPage + 2);
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
}
}));
// Enhanced Authentication Modal Component
Alpine.data('authModal', (defaultMode = 'login') => ({
open: false,
mode: defaultMode, // 'login' or 'register'
showPassword: false,
socialProviders: [
{id: 'google', name: 'Google', auth_url: '/accounts/google/login/'},
{id: 'discord', name: 'Discord', auth_url: '/accounts/discord/login/'}
],
socialLoading: false,
// Login form data
loginForm: {
username: '',
password: ''
},
loginLoading: false,
loginError: '',
// Register form data
registerForm: {
first_name: '',
last_name: '',
email: '',
username: '',
password1: '',
password2: ''
},
registerLoading: false,
registerError: '',
init() {
// Listen for auth modal events
this.$watch('open', (value) => {
if (value) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
this.resetForms();
}
});
},
show(mode = 'login') {
this.mode = mode;
this.open = true;
},
close() {
this.open = false;
},
switchToLogin() {
this.mode = 'login';
this.resetForms();
},
switchToRegister() {
this.mode = 'register';
this.resetForms();
},
resetForms() {
this.loginForm = { username: '', password: '' };
this.registerForm = {
first_name: '',
last_name: '',
email: '',
username: '',
password1: '',
password2: ''
};
this.loginError = '';
this.registerError = '';
this.showPassword = false;
},
async handleLogin() {
if (!this.loginForm.username || !this.loginForm.password) {
this.loginError = 'Please fill in all fields';
return;
}
this.loginLoading = true;
this.loginError = '';
try {
const response = await fetch('/accounts/login/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': this.getCSRFToken(),
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({
login: this.loginForm.username,
password: this.loginForm.password,
next: window.location.pathname
}),
redirect: 'manual' // Handle redirects manually
});
// Django allauth returns 302 redirect on successful login
if (response.status === 302 || (response.ok && response.status === 200)) {
// Check if login was successful by trying to parse response
try {
const html = await response.text();
// If response contains error messages, login failed
if (html.includes('errorlist') || html.includes('alert-danger') || html.includes('invalid')) {
this.loginError = this.extractErrorFromHTML(html) || 'Login failed. Please check your credentials.';
} else {
// Login successful - reload page to update auth state
window.location.reload();
}
} catch {
// If we can't parse response, assume success and reload
window.location.reload();
}
} else if (response.status === 200) {
// Form validation errors - parse HTML response for error messages
const html = await response.text();
this.loginError = this.extractErrorFromHTML(html) || 'Login failed. Please check your credentials.';
} else {
this.loginError = 'Login failed. Please check your credentials.';
}
} catch (error) {
console.error('Login error:', error);
this.loginError = 'An error occurred. Please try again.';
} finally {
this.loginLoading = false;
}
},
async handleRegister() {
if (!this.registerForm.first_name || !this.registerForm.last_name ||
!this.registerForm.email || !this.registerForm.username ||
!this.registerForm.password1 || !this.registerForm.password2) {
this.registerError = 'Please fill in all fields';
return;
}
if (this.registerForm.password1 !== this.registerForm.password2) {
this.registerError = 'Passwords do not match';
return;
}
this.registerLoading = true;
this.registerError = '';
try {
const response = await fetch('/accounts/signup/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': this.getCSRFToken(),
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({
first_name: this.registerForm.first_name,
last_name: this.registerForm.last_name,
email: this.registerForm.email,
username: this.registerForm.username,
password1: this.registerForm.password1,
password2: this.registerForm.password2
}),
redirect: 'manual'
});
if (response.status === 302 || response.ok) {
try {
const html = await response.text();
// Check if registration was successful
if (html.includes('errorlist') || html.includes('alert-danger') || html.includes('invalid')) {
this.registerError = this.extractErrorFromHTML(html) || 'Registration failed. Please try again.';
} else {
// Registration successful
this.close();
Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.');
}
} catch {
// Assume success if we can't parse response
this.close();
Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.');
}
} else if (response.status === 200) {
// Form validation errors
const html = await response.text();
this.registerError = this.extractErrorFromHTML(html) || 'Registration failed. Please try again.';
} else {
this.registerError = 'Registration failed. Please try again.';
}
} catch (error) {
console.error('Registration error:', error);
this.registerError = 'An error occurred. Please try again.';
} finally {
this.registerLoading = false;
}
},
handleSocialLogin(providerId) {
const provider = this.socialProviders.find(p => p.id === providerId);
if (!provider) {
Alpine.store('toast').error(`Social provider ${providerId} not found.`);
return;
}
// Redirect to social auth URL
window.location.href = provider.auth_url;
},
extractErrorFromHTML(html) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Look for error messages in various formats
const errorSelectors = [
'.errorlist li',
'.alert-danger',
'.invalid-feedback',
'.form-error',
'[class*="error"]',
'.field-error'
];
for (const selector of errorSelectors) {
const errorElements = doc.querySelectorAll(selector);
if (errorElements.length > 0) {
return Array.from(errorElements)
.map(el => el.textContent.trim())
.filter(text => text.length > 0)
.join(' ');
}
}
return null;
} catch (error) {
console.error('Error parsing HTML for error messages:', error);
return null;
}
},
getCSRFToken() {
const token = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.querySelector('meta[name=csrf-token]')?.getAttribute('content') ||
document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
return token || '';
}
}));
// Global Store for App State
Alpine.store('app', {
user: null,
theme: 'system',
searchQuery: '',
notifications: [],
setUser(user) {
this.user = user;
},
setTheme(theme) {
this.theme = theme;
localStorage.setItem('theme', theme);
},
addNotification(notification) {
this.notifications.push({
id: Date.now(),
...notification
});
},
removeNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
}
});
// Global Toast Store
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) {
const interval = setInterval(() => {
toast.progress -= (100 / (duration / 100));
if (toast.progress <= 0) {
clearInterval(interval);
this.hide(id);
}
}, 100);
}
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);
}
},
success(message, duration = 5000) {
return this.show(message, 'success', duration);
},
error(message, duration = 7000) {
return this.show(message, 'error', duration);
},
warning(message, duration = 6000) {
return this.show(message, 'warning', duration);
},
info(message, duration = 5000) {
return this.show(message, 'info', duration);
}
});
console.log('Alpine.js components registered successfully');
}
// Try multiple registration approaches
document.addEventListener('alpine:init', registerComponents);
document.addEventListener('DOMContentLoaded', registerComponents);
// Fallback - try after a delay
setTimeout(() => {
if (typeof Alpine !== 'undefined' && !componentsRegistered) {
registerComponents();
}
}, 100);

View File

@@ -1,665 +0,0 @@
/**
* ThrillWiki Dark Mode Maps - Dark Mode Integration for Maps
*
* This module provides comprehensive dark mode support for maps,
* including automatic theme switching, dark tile layers, and consistent styling
*/
class DarkModeMaps {
constructor(options = {}) {
this.options = {
enableAutoThemeDetection: true,
enableSystemPreference: true,
enableStoredPreference: true,
storageKey: 'thrillwiki_dark_mode',
transitionDuration: 300,
tileProviders: {
light: {
osm: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
cartodb: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
},
dark: {
osm: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
cartodb: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
}
},
...options
};
this.currentTheme = 'light';
this.mapInstances = new Set();
this.tileLayers = new Map();
this.observer = null;
this.mediaQuery = null;
this.init();
}
/**
* Initialize dark mode support
*/
init() {
this.detectCurrentTheme();
this.setupThemeObserver();
this.setupSystemPreferenceDetection();
this.setupStorageSync();
this.setupMapThemeStyles();
this.bindEventHandlers();
console.log('Dark mode maps initialized with theme:', this.currentTheme);
}
/**
* Detect current theme from DOM
*/
detectCurrentTheme() {
if (document.documentElement.classList.contains('dark')) {
this.currentTheme = 'dark';
} else {
this.currentTheme = 'light';
}
// Check stored preference
if (this.options.enableStoredPreference) {
const stored = localStorage.getItem(this.options.storageKey);
if (stored && ['light', 'dark', 'auto'].includes(stored)) {
this.applyStoredPreference(stored);
}
}
// Check system preference if auto
if (this.options.enableSystemPreference && this.getStoredPreference() === 'auto') {
this.applySystemPreference();
}
}
/**
* Setup theme observer to watch for changes
*/
setupThemeObserver() {
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
const newTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (newTheme !== this.currentTheme) {
this.handleThemeChange(newTheme);
}
}
});
});
this.observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
/**
* Setup system preference detection
*/
setupSystemPreferenceDetection() {
if (!this.options.enableSystemPreference) return;
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemChange = (e) => {
if (this.getStoredPreference() === 'auto') {
const newTheme = e.matches ? 'dark' : 'light';
this.setTheme(newTheme);
}
};
// Modern browsers
if (this.mediaQuery.addEventListener) {
this.mediaQuery.addEventListener('change', handleSystemChange);
} else {
// Fallback for older browsers
this.mediaQuery.addListener(handleSystemChange);
}
}
/**
* Setup storage synchronization
*/
setupStorageSync() {
// Listen for storage changes from other tabs
window.addEventListener('storage', (e) => {
if (e.key === this.options.storageKey) {
const newPreference = e.newValue;
if (newPreference) {
this.applyStoredPreference(newPreference);
}
}
});
}
/**
* Setup map theme styles
*/
setupMapThemeStyles() {
if (document.getElementById('dark-mode-map-styles')) return;
const styles = `
<style id="dark-mode-map-styles">
/* Light theme map styles */
.map-container {
transition: filter ${this.options.transitionDuration}ms ease;
}
/* Dark theme map styles */
.dark .map-container {
filter: brightness(0.9) contrast(1.1);
}
/* Dark theme popup styles */
.dark .leaflet-popup-content-wrapper {
background: #1F2937;
color: #F9FAFB;
border: 1px solid #374151;
}
.dark .leaflet-popup-tip {
background: #1F2937;
border: 1px solid #374151;
}
.dark .leaflet-popup-close-button {
color: #D1D5DB;
}
.dark .leaflet-popup-close-button:hover {
color: #F9FAFB;
}
/* Dark theme control styles */
.dark .leaflet-control-zoom a {
background-color: #374151;
border-color: #4B5563;
color: #F9FAFB;
}
.dark .leaflet-control-zoom a:hover {
background-color: #4B5563;
}
.dark .leaflet-control-attribution {
background: rgba(31, 41, 55, 0.8);
color: #D1D5DB;
}
/* Dark theme marker cluster styles */
.dark .cluster-marker-inner {
background: #1E40AF;
border-color: #1F2937;
}
.dark .cluster-marker-medium .cluster-marker-inner {
background: #059669;
}
.dark .cluster-marker-large .cluster-marker-inner {
background: #DC2626;
}
/* Dark theme location marker styles */
.dark .location-marker-inner {
border-color: #1F2937;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
}
/* Dark theme filter panel styles */
.dark .filter-chip.active {
background: #1E40AF;
color: #F9FAFB;
}
.dark .filter-chip.inactive {
background: #374151;
color: #D1D5DB;
}
.dark .filter-chip.inactive:hover {
background: #4B5563;
}
/* Dark theme road trip styles */
.dark .park-item {
background: #374151;
border-color: #4B5563;
}
.dark .park-item:hover {
background: #4B5563;
}
/* Dark theme search results */
.dark .search-result-item {
background: #374151;
border-color: #4B5563;
}
.dark .search-result-item:hover {
background: #4B5563;
}
/* Dark theme loading indicators */
.dark .htmx-indicator {
color: #D1D5DB;
}
/* Theme transition effects */
.theme-transition {
transition: background-color ${this.options.transitionDuration}ms ease,
color ${this.options.transitionDuration}ms ease,
border-color ${this.options.transitionDuration}ms ease;
}
/* Dark theme toggle button */
.theme-toggle {
position: relative;
width: 48px;
height: 24px;
background: #E5E7EB;
border-radius: 12px;
border: none;
cursor: pointer;
transition: background-color ${this.options.transitionDuration}ms ease;
}
.dark .theme-toggle {
background: #374151;
}
.theme-toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform ${this.options.transitionDuration}ms ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.dark .theme-toggle::after {
transform: translateX(24px);
background: #F9FAFB;
}
/* Theme icons */
.theme-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
transition: opacity ${this.options.transitionDuration}ms ease;
}
.theme-icon.sun {
left: 4px;
color: #F59E0B;
opacity: 1;
}
.theme-icon.moon {
right: 4px;
color: #6366F1;
opacity: 0;
}
.dark .theme-icon.sun {
opacity: 0;
}
.dark .theme-icon.moon {
opacity: 1;
}
/* System preference indicator */
.theme-auto-indicator {
font-size: 10px;
opacity: 0.6;
margin-left: 4px;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
/**
* Bind event handlers
*/
bindEventHandlers() {
// Handle theme toggle buttons
const themeToggleButtons = document.querySelectorAll('[data-theme-toggle]');
themeToggleButtons.forEach(button => {
button.addEventListener('click', () => {
this.toggleTheme();
});
});
// Handle theme selection
const themeSelectors = document.querySelectorAll('[data-theme-select]');
themeSelectors.forEach(selector => {
selector.addEventListener('change', (e) => {
this.setThemePreference(e.target.value);
});
});
}
/**
* Handle theme change
*/
handleThemeChange(newTheme) {
const oldTheme = this.currentTheme;
this.currentTheme = newTheme;
// Update map tile layers
this.updateMapTileLayers(newTheme);
// Update marker themes
this.updateMarkerThemes(newTheme);
// Emit theme change event
const event = new CustomEvent('themeChanged', {
detail: {
oldTheme,
newTheme,
isSystemPreference: this.getStoredPreference() === 'auto'
}
});
document.dispatchEvent(event);
console.log(`Theme changed from ${oldTheme} to ${newTheme}`);
}
/**
* Update map tile layers for theme
*/
updateMapTileLayers(theme) {
this.mapInstances.forEach(mapInstance => {
const currentTileLayer = this.tileLayers.get(mapInstance);
if (currentTileLayer) {
mapInstance.removeLayer(currentTileLayer);
}
// Create new tile layer for theme
const tileUrl = this.options.tileProviders[theme].osm;
const newTileLayer = L.tileLayer(tileUrl, {
attribution: '© OpenStreetMap contributors' + (theme === 'dark' ? ', © CARTO' : ''),
className: `map-tiles-${theme}`
});
newTileLayer.addTo(mapInstance);
this.tileLayers.set(mapInstance, newTileLayer);
});
}
/**
* Update marker themes
*/
updateMarkerThemes(theme) {
if (window.mapMarkers) {
// Clear marker caches to force re-render with new theme
window.mapMarkers.clearIconCache();
window.mapMarkers.clearPopupCache();
}
}
/**
* Toggle between light and dark themes
*/
toggleTheme() {
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
this.setTheme(newTheme);
this.setStoredPreference(newTheme);
}
/**
* Set specific theme
*/
setTheme(theme) {
if (!['light', 'dark'].includes(theme)) return;
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Update theme toggle states
this.updateThemeToggleStates(theme);
}
/**
* Set theme preference (light, dark, auto)
*/
setThemePreference(preference) {
if (!['light', 'dark', 'auto'].includes(preference)) return;
this.setStoredPreference(preference);
if (preference === 'auto') {
this.applySystemPreference();
} else {
this.setTheme(preference);
}
}
/**
* Apply system preference
*/
applySystemPreference() {
if (this.mediaQuery) {
const systemPrefersDark = this.mediaQuery.matches;
this.setTheme(systemPrefersDark ? 'dark' : 'light');
}
}
/**
* Apply stored preference
*/
applyStoredPreference(preference) {
if (preference === 'auto') {
this.applySystemPreference();
} else {
this.setTheme(preference);
}
}
/**
* Get stored theme preference
*/
getStoredPreference() {
return localStorage.getItem(this.options.storageKey) || 'auto';
}
/**
* Set stored theme preference
*/
setStoredPreference(preference) {
localStorage.setItem(this.options.storageKey, preference);
}
/**
* Update theme toggle button states
*/
updateThemeToggleStates(theme) {
const toggleButtons = document.querySelectorAll('[data-theme-toggle]');
toggleButtons.forEach(button => {
button.setAttribute('data-theme', theme);
button.setAttribute('aria-label', `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`);
});
const themeSelectors = document.querySelectorAll('[data-theme-select]');
themeSelectors.forEach(selector => {
selector.value = this.getStoredPreference();
});
}
/**
* Register map instance for theme management
*/
registerMapInstance(mapInstance) {
this.mapInstances.add(mapInstance);
// Apply current theme immediately
setTimeout(() => {
this.updateMapTileLayers(this.currentTheme);
}, 100);
}
/**
* Unregister map instance
*/
unregisterMapInstance(mapInstance) {
this.mapInstances.delete(mapInstance);
this.tileLayers.delete(mapInstance);
}
/**
* Create theme toggle button
*/
createThemeToggle() {
const toggle = document.createElement('button');
toggle.className = 'theme-toggle';
toggle.setAttribute('data-theme-toggle', 'true');
toggle.setAttribute('aria-label', 'Toggle theme');
toggle.innerHTML = `
<i class="theme-icon sun fas fa-sun"></i>
<i class="theme-icon moon fas fa-moon"></i>
`;
toggle.addEventListener('click', () => {
this.toggleTheme();
});
return toggle;
}
/**
* Create theme selector dropdown
*/
createThemeSelector() {
const selector = document.createElement('select');
selector.className = 'theme-selector';
selector.setAttribute('data-theme-select', 'true');
selector.innerHTML = `
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
`;
selector.value = this.getStoredPreference();
selector.addEventListener('change', (e) => {
this.setThemePreference(e.target.value);
});
return selector;
}
/**
* Get current theme
*/
getCurrentTheme() {
return this.currentTheme;
}
/**
* Check if dark mode is active
*/
isDarkMode() {
return this.currentTheme === 'dark';
}
/**
* Check if system preference is supported
*/
isSystemPreferenceSupported() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').media !== 'not all';
}
/**
* Get system preference
*/
getSystemPreference() {
if (this.isSystemPreferenceSupported() && this.mediaQuery) {
return this.mediaQuery.matches ? 'dark' : 'light';
}
return 'light';
}
/**
* Add theme transition classes
*/
addThemeTransitions() {
const elements = document.querySelectorAll('.filter-chip, .park-item, .search-result-item, .popup-btn');
elements.forEach(element => {
element.classList.add('theme-transition');
});
}
/**
* Remove theme transition classes
*/
removeThemeTransitions() {
const elements = document.querySelectorAll('.theme-transition');
elements.forEach(element => {
element.classList.remove('theme-transition');
});
}
/**
* Destroy dark mode instance
*/
destroy() {
if (this.observer) {
this.observer.disconnect();
}
if (this.mediaQuery) {
if (this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener('change', this.applySystemPreference);
} else {
this.mediaQuery.removeListener(this.applySystemPreference);
}
}
this.mapInstances.clear();
this.tileLayers.clear();
}
}
// Auto-initialize dark mode support
document.addEventListener('DOMContentLoaded', function() {
window.darkModeMaps = new DarkModeMaps();
// Register existing map instances
if (window.thrillwikiMap) {
window.darkModeMaps.registerMapInstance(window.thrillwikiMap);
}
// Add theme transitions
window.darkModeMaps.addThemeTransitions();
// Add theme toggle to navigation if it doesn't exist
const nav = document.querySelector('nav, .navbar, .header-nav');
if (nav && !nav.querySelector('[data-theme-toggle]')) {
const themeToggle = window.darkModeMaps.createThemeToggle();
themeToggle.style.marginLeft = 'auto';
nav.appendChild(themeToggle);
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = DarkModeMaps;
} else {
window.DarkModeMaps = DarkModeMaps;
}

View File

@@ -1,720 +0,0 @@
/**
* ThrillWiki Geolocation - User Location and "Near Me" Functionality
*
* This module handles browser geolocation API integration with privacy-conscious
* permission handling, distance calculations, and "near me" functionality
*/
class UserLocation {
constructor(options = {}) {
this.options = {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 300000, // 5 minutes
watchPosition: false,
autoShowOnMap: true,
showAccuracyCircle: true,
enableCaching: true,
cacheKey: 'thrillwiki_user_location',
apiEndpoints: {
nearby: '/api/map/nearby/',
distance: '/api/map/distance/'
},
defaultRadius: 50, // miles
maxRadius: 500,
...options
};
this.currentPosition = null;
this.watchId = null;
this.mapInstance = null;
this.locationMarker = null;
this.accuracyCircle = null;
this.permissionState = 'unknown';
this.lastLocationTime = null;
// Event handlers
this.eventHandlers = {
locationFound: [],
locationError: [],
permissionChanged: []
};
this.init();
}
/**
* Initialize the geolocation component
*/
init() {
this.checkGeolocationSupport();
this.loadCachedLocation();
this.setupLocationButtons();
this.checkPermissionState();
}
/**
* Check if geolocation is supported by the browser
*/
checkGeolocationSupport() {
if (!navigator.geolocation) {
console.warn('Geolocation is not supported by this browser');
this.hideLocationButtons();
return false;
}
return true;
}
/**
* Setup location-related buttons and controls
*/
setupLocationButtons() {
// Find all "locate me" buttons
const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn');
locateButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
this.requestLocation();
});
});
// Find "near me" buttons
const nearMeButtons = document.querySelectorAll('[data-action="near-me"], .near-me-btn');
nearMeButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
this.showNearbyLocations();
});
});
// Distance calculator buttons
const distanceButtons = document.querySelectorAll('[data-action="calculate-distance"]');
distanceButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const targetLat = parseFloat(button.dataset.lat);
const targetLng = parseFloat(button.dataset.lng);
this.calculateDistance(targetLat, targetLng);
});
});
}
/**
* Hide location buttons when geolocation is not supported
*/
hideLocationButtons() {
const locationElements = document.querySelectorAll('.geolocation-feature');
locationElements.forEach(el => {
el.style.display = 'none';
});
}
/**
* Check current permission state
*/
async checkPermissionState() {
if ('permissions' in navigator) {
try {
const permission = await navigator.permissions.query({ name: 'geolocation' });
this.permissionState = permission.state;
this.updateLocationButtonStates();
// Listen for permission changes
permission.addEventListener('change', () => {
this.permissionState = permission.state;
this.updateLocationButtonStates();
this.triggerEvent('permissionChanged', this.permissionState);
});
} catch (error) {
console.warn('Could not check geolocation permission:', error);
}
}
}
/**
* Update location button states based on permission
*/
updateLocationButtonStates() {
const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn');
locateButtons.forEach(button => {
const icon = button.querySelector('i') || button;
switch (this.permissionState) {
case 'granted':
button.disabled = false;
button.title = 'Find my location';
icon.className = 'fas fa-crosshairs';
break;
case 'denied':
button.disabled = true;
button.title = 'Location access denied';
icon.className = 'fas fa-times-circle';
break;
case 'prompt':
default:
button.disabled = false;
button.title = 'Find my location (permission required)';
icon.className = 'fas fa-crosshairs';
break;
}
});
}
/**
* Request user location
*/
requestLocation(options = {}) {
if (!navigator.geolocation) {
this.handleLocationError(new Error('Geolocation not supported'));
return;
}
const requestOptions = {
...this.options,
...options
};
// Show loading state
this.setLocationButtonLoading(true);
navigator.geolocation.getCurrentPosition(
(position) => this.handleLocationSuccess(position),
(error) => this.handleLocationError(error),
{
enableHighAccuracy: requestOptions.enableHighAccuracy,
timeout: requestOptions.timeout,
maximumAge: requestOptions.maximumAge
}
);
}
/**
* Start watching user position
*/
startWatching() {
if (!navigator.geolocation || this.watchId) return;
this.watchId = navigator.geolocation.watchPosition(
(position) => this.handleLocationSuccess(position),
(error) => this.handleLocationError(error),
{
enableHighAccuracy: this.options.enableHighAccuracy,
timeout: this.options.timeout,
maximumAge: this.options.maximumAge
}
);
}
/**
* Stop watching user position
*/
stopWatching() {
if (this.watchId) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
}
/**
* Handle successful location acquisition
*/
handleLocationSuccess(position) {
this.currentPosition = {
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracy: position.coords.accuracy,
timestamp: position.timestamp
};
this.lastLocationTime = Date.now();
// Cache location
if (this.options.enableCaching) {
this.cacheLocation(this.currentPosition);
}
// Show on map if enabled
if (this.options.autoShowOnMap && this.mapInstance) {
this.showLocationOnMap();
}
// Update button states
this.setLocationButtonLoading(false);
this.updateLocationButtonStates();
// Trigger event
this.triggerEvent('locationFound', this.currentPosition);
console.log('Location found:', this.currentPosition);
}
/**
* Handle location errors
*/
handleLocationError(error) {
this.setLocationButtonLoading(false);
let message = 'Unable to get your location';
switch (error.code) {
case error.PERMISSION_DENIED:
message = 'Location access denied. Please enable location services.';
this.permissionState = 'denied';
break;
case error.POSITION_UNAVAILABLE:
message = 'Location information is unavailable.';
break;
case error.TIMEOUT:
message = 'Location request timed out.';
break;
default:
message = 'An unknown error occurred while retrieving location.';
break;
}
this.showLocationMessage(message, 'error');
this.updateLocationButtonStates();
// Trigger event
this.triggerEvent('locationError', { error, message });
console.error('Location error:', error);
}
/**
* Show user location on map
*/
showLocationOnMap() {
if (!this.mapInstance || !this.currentPosition) return;
const { lat, lng, accuracy } = this.currentPosition;
// Remove existing location marker and circle
this.clearLocationDisplay();
// Add location marker
this.locationMarker = L.marker([lat, lng], {
icon: this.createUserLocationIcon()
}).addTo(this.mapInstance);
this.locationMarker.bindPopup(`
<div class="user-location-popup">
<h4><i class="fas fa-map-marker-alt"></i> Your Location</h4>
<p class="accuracy">Accuracy: ±${Math.round(accuracy)}m</p>
<div class="location-actions">
<button onclick="userLocation.showNearbyLocations()" class="btn btn-primary btn-sm">
<i class="fas fa-search"></i> Find Nearby Parks
</button>
</div>
</div>
`);
// Add accuracy circle if enabled and accuracy is reasonable
if (this.options.showAccuracyCircle && accuracy < 1000) {
this.accuracyCircle = L.circle([lat, lng], {
radius: accuracy,
fillColor: '#3388ff',
fillOpacity: 0.2,
color: '#3388ff',
weight: 2,
opacity: 0.5
}).addTo(this.mapInstance);
}
// Center map on user location
this.mapInstance.setView([lat, lng], 13);
}
/**
* Create custom icon for user location
*/
createUserLocationIcon() {
return L.divIcon({
className: 'user-location-marker',
html: `
<div class="user-location-inner">
<i class="fas fa-crosshairs"></i>
</div>
`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
/**
* Clear location display from map
*/
clearLocationDisplay() {
if (this.locationMarker && this.mapInstance) {
this.mapInstance.removeLayer(this.locationMarker);
this.locationMarker = null;
}
if (this.accuracyCircle && this.mapInstance) {
this.mapInstance.removeLayer(this.accuracyCircle);
this.accuracyCircle = null;
}
}
/**
* Show nearby locations
*/
async showNearbyLocations(radius = null) {
if (!this.currentPosition) {
this.requestLocation();
return;
}
try {
const searchRadius = radius || this.options.defaultRadius;
const { lat, lng } = this.currentPosition;
const params = new URLSearchParams({
lat: lat,
lng: lng,
radius: searchRadius,
unit: 'miles'
});
const response = await fetch(`${this.options.apiEndpoints.nearby}?${params}`);
const data = await response.json();
if (data.status === 'success') {
this.displayNearbyResults(data.data);
} else {
this.showLocationMessage('No nearby locations found', 'info');
}
} catch (error) {
console.error('Failed to find nearby locations:', error);
this.showLocationMessage('Failed to find nearby locations', 'error');
}
}
/**
* Display nearby search results
*/
displayNearbyResults(results) {
// Find or create results container
let resultsContainer = document.getElementById('nearby-results');
if (!resultsContainer) {
resultsContainer = document.createElement('div');
resultsContainer.id = 'nearby-results';
resultsContainer.className = 'nearby-results-container';
// Try to insert after a logical element
const mapContainer = document.getElementById('map-container');
if (mapContainer && mapContainer.parentNode) {
mapContainer.parentNode.insertBefore(resultsContainer, mapContainer.nextSibling);
} else {
document.body.appendChild(resultsContainer);
}
}
const html = `
<div class="nearby-results">
<h3 class="results-title">
<i class="fas fa-map-marker-alt"></i>
Nearby Parks (${results.length} found)
</h3>
<div class="results-list">
${results.map(location => `
<div class="nearby-item">
<div class="location-info">
<h4 class="location-name">${location.name}</h4>
<p class="location-address">${location.formatted_location || ''}</p>
<p class="location-distance">
<i class="fas fa-route"></i>
${location.distance} away
</p>
</div>
<div class="location-actions">
<button onclick="userLocation.centerOnLocation(${location.latitude}, ${location.longitude})"
class="btn btn-outline btn-sm">
<i class="fas fa-map"></i> Show on Map
</button>
</div>
</div>
`).join('')}
</div>
</div>
`;
resultsContainer.innerHTML = html;
// Scroll to results
resultsContainer.scrollIntoView({ behavior: 'smooth' });
}
/**
* Calculate distance to a specific location
*/
async calculateDistance(targetLat, targetLng) {
if (!this.currentPosition) {
this.showLocationMessage('Please enable location services first', 'warning');
return null;
}
try {
const { lat, lng } = this.currentPosition;
const params = new URLSearchParams({
from_lat: lat,
from_lng: lng,
to_lat: targetLat,
to_lng: targetLng
});
const response = await fetch(`${this.options.apiEndpoints.distance}?${params}`);
const data = await response.json();
if (data.status === 'success') {
return data.data;
}
} catch (error) {
console.error('Failed to calculate distance:', error);
}
// Fallback to Haversine formula
return this.calculateHaversineDistance(
this.currentPosition.lat,
this.currentPosition.lng,
targetLat,
targetLng
);
}
/**
* Calculate distance using Haversine formula
*/
calculateHaversineDistance(lat1, lng1, lat2, lng2) {
const R = 3959; // Earth's radius in miles
const dLat = this.toRadians(lat2 - lat1);
const dLng = this.toRadians(lng2 - lng1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return {
distance: Math.round(distance * 10) / 10,
unit: 'miles'
};
}
/**
* Convert degrees to radians
*/
toRadians(degrees) {
return degrees * (Math.PI / 180);
}
/**
* Center map on specific location
*/
centerOnLocation(lat, lng, zoom = 15) {
if (this.mapInstance) {
this.mapInstance.setView([lat, lng], zoom);
}
}
/**
* Cache user location
*/
cacheLocation(position) {
try {
const cacheData = {
position: position,
timestamp: Date.now()
};
localStorage.setItem(this.options.cacheKey, JSON.stringify(cacheData));
} catch (error) {
console.warn('Failed to cache location:', error);
}
}
/**
* Load cached location
*/
loadCachedLocation() {
if (!this.options.enableCaching) return null;
try {
const cached = localStorage.getItem(this.options.cacheKey);
if (!cached) return null;
const cacheData = JSON.parse(cached);
const age = Date.now() - cacheData.timestamp;
// Check if cache is still valid (5 minutes)
if (age < this.options.maximumAge) {
this.currentPosition = cacheData.position;
this.lastLocationTime = cacheData.timestamp;
return this.currentPosition;
} else {
// Remove expired cache
localStorage.removeItem(this.options.cacheKey);
}
} catch (error) {
console.warn('Failed to load cached location:', error);
}
return null;
}
/**
* Set loading state for location buttons
*/
setLocationButtonLoading(loading) {
const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn');
locateButtons.forEach(button => {
const icon = button.querySelector('i') || button;
if (loading) {
button.disabled = true;
icon.className = 'fas fa-spinner fa-spin';
} else {
button.disabled = false;
// Icon will be updated by updateLocationButtonStates
}
});
}
/**
* Show location-related message
*/
showLocationMessage(message, type = 'info') {
// Create or update message element
let messageEl = document.getElementById('location-message');
if (!messageEl) {
messageEl = document.createElement('div');
messageEl.id = 'location-message';
messageEl.className = 'location-message';
// Insert at top of page or after header
const header = document.querySelector('header, .header');
if (header) {
header.parentNode.insertBefore(messageEl, header.nextSibling);
} else {
document.body.insertBefore(messageEl, document.body.firstChild);
}
}
messageEl.textContent = message;
messageEl.className = `location-message location-message-${type}`;
messageEl.style.display = 'block';
// Auto-hide after delay
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.style.display = 'none';
}
}, 5000);
}
/**
* Connect to a map instance
*/
connectToMap(mapInstance) {
this.mapInstance = mapInstance;
// Show cached location on map if available
if (this.currentPosition && this.options.autoShowOnMap) {
this.showLocationOnMap();
}
}
/**
* Get current position
*/
getCurrentPosition() {
return this.currentPosition;
}
/**
* Check if location is available
*/
hasLocation() {
return this.currentPosition !== null;
}
/**
* Check if location is recent
*/
isLocationRecent(maxAge = 300000) { // 5 minutes default
if (!this.lastLocationTime) return false;
return (Date.now() - this.lastLocationTime) < maxAge;
}
/**
* Add event listener
*/
on(event, handler) {
if (!this.eventHandlers[event]) {
this.eventHandlers[event] = [];
}
this.eventHandlers[event].push(handler);
}
/**
* Remove event listener
*/
off(event, handler) {
if (this.eventHandlers[event]) {
const index = this.eventHandlers[event].indexOf(handler);
if (index > -1) {
this.eventHandlers[event].splice(index, 1);
}
}
}
/**
* Trigger event
*/
triggerEvent(event, data) {
if (this.eventHandlers[event]) {
this.eventHandlers[event].forEach(handler => {
try {
handler(data);
} catch (error) {
console.error(`Error in ${event} handler:`, error);
}
});
}
}
/**
* Destroy the geolocation instance
*/
destroy() {
this.stopWatching();
this.clearLocationDisplay();
this.eventHandlers = {};
}
}
// Auto-initialize user location
document.addEventListener('DOMContentLoaded', function() {
window.userLocation = new UserLocation();
// Connect to map instance if available
if (window.thrillwikiMap) {
window.userLocation.connectToMap(window.thrillwikiMap);
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = UserLocation;
} else {
window.UserLocation = UserLocation;
}

View File

@@ -1,725 +0,0 @@
/**
* ThrillWiki HTMX Maps Integration - Dynamic Map Updates via HTMX
*
* This module handles HTMX events for map updates, manages loading states
* during API calls, updates map content based on HTMX responses, and provides
* error handling for failed requests
*/
class HTMXMapIntegration {
constructor(options = {}) {
this.options = {
mapInstance: null,
filterInstance: null,
defaultTarget: '#map-container',
loadingClass: 'htmx-loading',
errorClass: 'htmx-error',
successClass: 'htmx-success',
loadingTimeout: 30000, // 30 seconds
retryAttempts: 3,
retryDelay: 1000,
...options
};
this.loadingElements = new Set();
this.activeRequests = new Map();
this.requestQueue = [];
this.retryCount = new Map();
this.init();
}
/**
* Initialize HTMX integration
*/
init() {
if (typeof htmx === 'undefined') {
console.warn('HTMX not found, map integration disabled');
return;
}
this.setupEventHandlers();
this.setupCustomEvents();
this.setupErrorHandling();
this.enhanceExistingElements();
}
/**
* Setup HTMX event handlers
*/
setupEventHandlers() {
// Before request - show loading states
document.addEventListener('htmx:beforeRequest', (e) => {
this.handleBeforeRequest(e);
});
// After request - handle response and update maps
document.addEventListener('htmx:afterRequest', (e) => {
this.handleAfterRequest(e);
});
// Response error - handle failed requests
document.addEventListener('htmx:responseError', (e) => {
this.handleResponseError(e);
});
// Send error - handle network errors
document.addEventListener('htmx:sendError', (e) => {
this.handleSendError(e);
});
// Timeout - handle request timeouts
document.addEventListener('htmx:timeout', (e) => {
this.handleTimeout(e);
});
// Before swap - prepare for content updates
document.addEventListener('htmx:beforeSwap', (e) => {
this.handleBeforeSwap(e);
});
// After swap - update maps with new content
document.addEventListener('htmx:afterSwap', (e) => {
this.handleAfterSwap(e);
});
// Config request - modify requests before sending
document.addEventListener('htmx:configRequest', (e) => {
this.handleConfigRequest(e);
});
}
/**
* Setup custom map-specific events
*/
setupCustomEvents() {
// Custom event for map data updates
document.addEventListener('map:dataUpdate', (e) => {
this.handleMapDataUpdate(e);
});
// Custom event for filter changes
document.addEventListener('filter:changed', (e) => {
this.handleFilterChange(e);
});
// Custom event for search updates
document.addEventListener('search:results', (e) => {
this.handleSearchResults(e);
});
}
/**
* Setup global error handling
*/
setupErrorHandling() {
// Global error handler
window.addEventListener('error', (e) => {
if (e.filename && e.filename.includes('htmx')) {
console.error('HTMX error:', e.error);
this.showErrorMessage('An error occurred while updating the map');
}
});
// Unhandled promise rejection handler
window.addEventListener('unhandledrejection', (e) => {
if (e.reason && e.reason.toString().includes('htmx')) {
console.error('HTMX promise rejection:', e.reason);
this.showErrorMessage('Failed to complete map request');
}
});
}
/**
* Enhance existing elements with HTMX map functionality
*/
enhanceExistingElements() {
// Add map-specific attributes to filter forms
const filterForms = document.querySelectorAll('[data-map-filter]');
filterForms.forEach(form => {
if (!form.hasAttribute('hx-get')) {
form.setAttribute('hx-get', form.getAttribute('data-map-filter'));
form.setAttribute('hx-trigger', 'change, submit');
form.setAttribute('hx-target', '#map-container');
form.setAttribute('hx-swap', 'none');
}
});
// Add map update attributes to search inputs
const searchInputs = document.querySelectorAll('[data-map-search]');
searchInputs.forEach(input => {
if (!input.hasAttribute('hx-get')) {
input.setAttribute('hx-get', input.getAttribute('data-map-search'));
input.setAttribute('hx-trigger', 'input changed delay:500ms');
input.setAttribute('hx-target', '#search-results');
}
});
}
/**
* Handle before request event
*/
handleBeforeRequest(e) {
const element = e.target;
const requestId = this.generateRequestId();
// Store request information
this.activeRequests.set(requestId, {
element: element,
startTime: Date.now(),
url: e.detail.requestConfig.path
});
// Show loading state
this.showLoadingState(element, true);
// Add request ID to detail for tracking
e.detail.requestId = requestId;
// Set timeout
setTimeout(() => {
if (this.activeRequests.has(requestId)) {
this.handleTimeout({ detail: { requestId } });
}
}, this.options.loadingTimeout);
console.log('HTMX request started:', e.detail.requestConfig.path);
}
/**
* Handle after request event
*/
handleAfterRequest(e) {
const requestId = e.detail.requestId;
const request = this.activeRequests.get(requestId);
if (request) {
const duration = Date.now() - request.startTime;
console.log(`HTMX request completed in ${duration}ms:`, request.url);
this.activeRequests.delete(requestId);
this.showLoadingState(request.element, false);
}
if (e.detail.successful) {
this.handleSuccessfulResponse(e);
} else {
this.handleFailedResponse(e);
}
}
/**
* Handle successful response
*/
handleSuccessfulResponse(e) {
const element = e.target;
// Add success class temporarily
element.classList.add(this.options.successClass);
setTimeout(() => {
element.classList.remove(this.options.successClass);
}, 2000);
// Reset retry count
this.retryCount.delete(element);
// Check if this is a map-related request
if (this.isMapRequest(e)) {
this.updateMapFromResponse(e);
}
}
/**
* Handle failed response
*/
handleFailedResponse(e) {
const element = e.target;
// Add error class
element.classList.add(this.options.errorClass);
setTimeout(() => {
element.classList.remove(this.options.errorClass);
}, 5000);
// Check if we should retry
if (this.shouldRetry(element)) {
this.scheduleRetry(element, e.detail);
} else {
this.showErrorMessage('Failed to update map data');
}
}
/**
* Handle response error
*/
handleResponseError(e) {
console.error('HTMX response error:', e.detail);
const element = e.target;
const status = e.detail.xhr.status;
let message = 'An error occurred while updating the map';
switch (status) {
case 400:
message = 'Invalid request parameters';
break;
case 401:
message = 'Authentication required';
break;
case 403:
message = 'Access denied';
break;
case 404:
message = 'Map data not found';
break;
case 429:
message = 'Too many requests. Please wait a moment.';
break;
case 500:
message = 'Server error. Please try again later.';
break;
}
this.showErrorMessage(message);
this.showLoadingState(element, false);
}
/**
* Handle send error
*/
handleSendError(e) {
console.error('HTMX send error:', e.detail);
this.showErrorMessage('Network error. Please check your connection.');
this.showLoadingState(e.target, false);
}
/**
* Handle timeout
*/
handleTimeout(e) {
console.warn('HTMX request timeout');
if (e.detail.requestId) {
const request = this.activeRequests.get(e.detail.requestId);
if (request) {
this.showLoadingState(request.element, false);
this.activeRequests.delete(e.detail.requestId);
}
}
this.showErrorMessage('Request timed out. Please try again.');
}
/**
* Handle before swap
*/
handleBeforeSwap(e) {
// Prepare map for content update
if (this.isMapRequest(e)) {
console.log('Preparing map for content swap');
}
}
/**
* Handle after swap
*/
handleAfterSwap(e) {
// Re-initialize any new HTMX elements
this.enhanceExistingElements();
// Update maps if needed
if (this.isMapRequest(e)) {
this.reinitializeMapComponents();
}
}
/**
* Handle config request
*/
handleConfigRequest(e) {
const config = e.detail;
// Add CSRF token if available
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken && (config.verb === 'post' || config.verb === 'put' || config.verb === 'patch')) {
config.headers['X-CSRFToken'] = csrfToken.value;
}
// Add map-specific headers
if (this.isMapRequest(e)) {
config.headers['X-Map-Request'] = 'true';
// Add current map bounds if available
if (this.options.mapInstance) {
const bounds = this.options.mapInstance.getBounds();
if (bounds) {
config.headers['X-Map-Bounds'] = JSON.stringify({
north: bounds.getNorth(),
south: bounds.getSouth(),
east: bounds.getEast(),
west: bounds.getWest(),
zoom: this.options.mapInstance.getZoom()
});
}
}
}
}
/**
* Handle map data updates
*/
handleMapDataUpdate(e) {
if (this.options.mapInstance) {
const data = e.detail;
this.options.mapInstance.updateMarkers(data);
}
}
/**
* Handle filter changes
*/
handleFilterChange(e) {
if (this.options.filterInstance) {
const filters = e.detail;
// Trigger HTMX request for filter update
const filterForm = document.getElementById('map-filters');
if (filterForm && filterForm.hasAttribute('hx-get')) {
htmx.trigger(filterForm, 'change');
}
}
}
/**
* Handle search results
*/
handleSearchResults(e) {
const results = e.detail;
// Update map with search results if applicable
if (results.locations && this.options.mapInstance) {
this.options.mapInstance.updateMarkers({ locations: results.locations });
}
}
/**
* Show/hide loading state
*/
showLoadingState(element, show) {
if (show) {
element.classList.add(this.options.loadingClass);
this.loadingElements.add(element);
// Show loading indicators
const indicators = element.querySelectorAll('.htmx-indicator');
indicators.forEach(indicator => {
indicator.style.display = 'block';
});
// Disable form elements
const inputs = element.querySelectorAll('input, button, select');
inputs.forEach(input => {
input.disabled = true;
});
} else {
element.classList.remove(this.options.loadingClass);
this.loadingElements.delete(element);
// Hide loading indicators
const indicators = element.querySelectorAll('.htmx-indicator');
indicators.forEach(indicator => {
indicator.style.display = 'none';
});
// Re-enable form elements
const inputs = element.querySelectorAll('input, button, select');
inputs.forEach(input => {
input.disabled = false;
});
}
}
/**
* Check if request is map-related
*/
isMapRequest(e) {
const element = e.target;
const url = e.detail.requestConfig ? e.detail.requestConfig.path : '';
return element.hasAttribute('data-map-filter') ||
element.hasAttribute('data-map-search') ||
element.closest('[data-map-target]') ||
url.includes('/api/map/') ||
url.includes('/maps/');
}
/**
* Update map from HTMX response
*/
updateMapFromResponse(e) {
if (!this.options.mapInstance) return;
try {
// Try to extract map data from response
const responseText = e.detail.xhr.responseText;
// If response is JSON, update map directly
try {
const data = JSON.parse(responseText);
if (data.status === 'success' && data.data) {
this.options.mapInstance.updateMarkers(data.data);
}
} catch (jsonError) {
// If not JSON, look for data attributes in HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = responseText;
const mapData = tempDiv.querySelector('[data-map-data]');
if (mapData) {
const data = JSON.parse(mapData.getAttribute('data-map-data'));
this.options.mapInstance.updateMarkers(data);
}
}
} catch (error) {
console.error('Failed to update map from response:', error);
}
}
/**
* Check if element should be retried
*/
shouldRetry(element) {
const retryCount = this.retryCount.get(element) || 0;
return retryCount < this.options.retryAttempts;
}
/**
* Schedule retry for failed request
*/
scheduleRetry(element, detail) {
const retryCount = (this.retryCount.get(element) || 0) + 1;
this.retryCount.set(element, retryCount);
const delay = this.options.retryDelay * Math.pow(2, retryCount - 1); // Exponential backoff
setTimeout(() => {
console.log(`Retrying HTMX request (attempt ${retryCount})`);
htmx.trigger(element, 'retry');
}, delay);
}
/**
* Show error message to user
*/
showErrorMessage(message) {
// Create or update error message element
let errorEl = document.getElementById('htmx-error-message');
if (!errorEl) {
errorEl = document.createElement('div');
errorEl.id = 'htmx-error-message';
errorEl.className = 'htmx-error-message';
// Insert at top of page
document.body.insertBefore(errorEl, document.body.firstChild);
}
errorEl.innerHTML = `
<div class="error-content">
<i class="fas fa-exclamation-circle"></i>
<span>${message}</span>
<button onclick="this.parentElement.parentElement.remove()" class="error-close">
<i class="fas fa-times"></i>
</button>
</div>
`;
errorEl.style.display = 'block';
// Auto-hide after 10 seconds
setTimeout(() => {
if (errorEl.parentNode) {
errorEl.remove();
}
}, 10000);
}
/**
* Reinitialize map components after content swap
*/
reinitializeMapComponents() {
// Reinitialize filter components
if (this.options.filterInstance) {
this.options.filterInstance.init();
}
// Reinitialize any new map containers
const newMapContainers = document.querySelectorAll('[data-map="auto"]:not([data-initialized])');
newMapContainers.forEach(container => {
container.setAttribute('data-initialized', 'true');
// Initialize new map instance if needed
});
}
/**
* Generate unique request ID
*/
generateRequestId() {
return `htmx-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Connect to map instance
*/
connectToMap(mapInstance) {
this.options.mapInstance = mapInstance;
}
/**
* Connect to filter instance
*/
connectToFilter(filterInstance) {
this.options.filterInstance = filterInstance;
}
/**
* Get active request count
*/
getActiveRequestCount() {
return this.activeRequests.size;
}
/**
* Cancel all active requests
*/
cancelAllRequests() {
this.activeRequests.forEach((request, id) => {
this.showLoadingState(request.element, false);
});
this.activeRequests.clear();
}
/**
* Get loading elements
*/
getLoadingElements() {
return Array.from(this.loadingElements);
}
}
// Auto-initialize HTMX integration
document.addEventListener('DOMContentLoaded', function() {
window.htmxMapIntegration = new HTMXMapIntegration();
// Connect to existing instances
if (window.thrillwikiMap) {
window.htmxMapIntegration.connectToMap(window.thrillwikiMap);
}
if (window.mapFilters) {
window.htmxMapIntegration.connectToFilter(window.mapFilters);
}
});
// Add styles for HTMX integration
document.addEventListener('DOMContentLoaded', function() {
if (document.getElementById('htmx-map-styles')) return;
const styles = `
<style id="htmx-map-styles">
.htmx-loading {
opacity: 0.7;
pointer-events: none;
}
.htmx-error {
border-color: #EF4444;
background-color: #FEE2E2;
}
.htmx-success {
border-color: #10B981;
background-color: #D1FAE5;
}
.htmx-indicator {
display: none;
}
.htmx-error-message {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
max-width: 400px;
background: #FEE2E2;
border: 1px solid #FCA5A5;
border-radius: 8px;
padding: 0;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
animation: slideInRight 0.3s ease;
}
.error-content {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
color: #991B1B;
font-size: 14px;
}
.error-close {
background: none;
border: none;
color: #991B1B;
cursor: pointer;
padding: 4px;
margin-left: auto;
}
.error-close:hover {
color: #7F1D1D;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Dark mode styles */
.dark .htmx-error-message {
background: #7F1D1D;
border-color: #991B1B;
}
.dark .error-content {
color: #FCA5A5;
}
.dark .error-close {
color: #FCA5A5;
}
.dark .error-close:hover {
color: #F87171;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = HTMXMapIntegration;
} else {
window.HTMXMapIntegration = HTMXMapIntegration;
}

View File

@@ -1,81 +0,0 @@
function locationAutocomplete(field, filterParks = false) {
return {
query: '',
suggestions: [],
fetchSuggestions() {
let url;
const params = new URLSearchParams({
q: this.query,
filter_parks: filterParks
});
switch (field) {
case 'country':
url = '/parks/ajax/countries/';
break;
case 'region':
url = '/parks/ajax/regions/';
// Add country parameter if we're fetching regions
const countryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (countryInput && countryInput.value) {
params.append('country', countryInput.value);
}
break;
case 'city':
url = '/parks/ajax/cities/';
// Add country and region parameters if we're fetching cities
const regionInput = document.getElementById(filterParks ? 'region' : 'id_region_name');
const cityCountryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (regionInput && regionInput.value && cityCountryInput && cityCountryInput.value) {
params.append('country', cityCountryInput.value);
params.append('region', regionInput.value);
}
break;
}
if (url) {
fetch(`${url}?${params}`)
.then(response => response.json())
.then(data => {
this.suggestions = data;
});
}
},
selectSuggestion(suggestion) {
this.query = suggestion.name;
this.suggestions = [];
// If this is a form field (not filter), update hidden fields
if (!filterParks) {
const hiddenField = document.getElementById(`id_${field}`);
if (hiddenField) {
hiddenField.value = suggestion.id;
}
// Clear dependent fields when parent field changes
if (field === 'country') {
const regionInput = document.getElementById('id_region_name');
const cityInput = document.getElementById('id_city_name');
const regionHidden = document.getElementById('id_region');
const cityHidden = document.getElementById('id_city');
if (regionInput) regionInput.value = '';
if (cityInput) cityInput.value = '';
if (regionHidden) regionHidden.value = '';
if (cityHidden) cityHidden.value = '';
} else if (field === 'region') {
const cityInput = document.getElementById('id_city_name');
const cityHidden = document.getElementById('id_city');
if (cityInput) cityInput.value = '';
if (cityHidden) cityHidden.value = '';
}
}
// Trigger form submission for filters
if (filterParks) {
htmx.trigger('#park-filters', 'change');
}
}
};
}

View File

@@ -1,54 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
const useLocationBtn = document.getElementById('use-my-location');
const latInput = document.getElementById('lat-input');
const lngInput = document.getElementById('lng-input');
const locationInput = document.getElementById('location-input');
if (useLocationBtn && 'geolocation' in navigator) {
useLocationBtn.addEventListener('click', function() {
this.textContent = '📍 Getting location...';
this.disabled = true;
navigator.geolocation.getCurrentPosition(
function(position) {
latInput.value = position.coords.latitude;
lngInput.value = position.coords.longitude;
locationInput.value = `${position.coords.latitude.toFixed(6)}, ${position.coords.longitude.toFixed(6)}`;
useLocationBtn.textContent = '✅ Location set';
setTimeout(() => {
useLocationBtn.textContent = '📍 Use My Location';
useLocationBtn.disabled = false;
}, 2000);
},
function(error) {
useLocationBtn.textContent = '❌ Location failed';
console.error('Geolocation error:', error);
setTimeout(() => {
useLocationBtn.textContent = '📍 Use My Location';
useLocationBtn.disabled = false;
}, 2000);
}
);
});
} else if (useLocationBtn) {
useLocationBtn.style.display = 'none';
}
// Autocomplete for location search
if (locationInput) {
locationInput.addEventListener('input', function() {
const query = this.value;
if (query.length < 3) {
return;
}
fetch(`/search/location/suggestions/?q=${query}`)
.then(response => response.json())
.then(data => {
// This is a simplified example. A more robust solution would use a library like Awesomplete or build a custom dropdown.
console.log('Suggestions:', data.suggestions);
})
.catch(error => console.error('Error fetching suggestions:', error));
});
}
});

View File

@@ -1,121 +0,0 @@
// Theme handling
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle');
const html = document.documentElement;
// Initialize toggle state based on current theme
if (themeToggle) {
themeToggle.checked = html.classList.contains('dark');
// Handle toggle changes
themeToggle.addEventListener('change', function() {
const isDark = this.checked;
html.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
const isDark = e.matches;
html.classList.toggle('dark', isDark);
themeToggle.checked = isDark;
}
});
}
});
// Handle search form submission
document.addEventListener('submit', (e) => {
if (e.target.matches('form[action*="search"]')) {
const searchInput = e.target.querySelector('input[name="q"]');
if (!searchInput.value.trim()) {
e.preventDefault();
}
}
});
// Mobile menu toggle with transitions
document.addEventListener('DOMContentLoaded', () => {
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
const mobileMenu = document.getElementById('mobileMenu');
if (mobileMenuBtn && mobileMenu) {
let isMenuOpen = false;
const toggleMenu = () => {
isMenuOpen = !isMenuOpen;
mobileMenu.classList.toggle('show', isMenuOpen);
mobileMenuBtn.setAttribute('aria-expanded', isMenuOpen.toString());
// Update icon
const icon = mobileMenuBtn.querySelector('i');
if (icon) {
icon.classList.remove(isMenuOpen ? 'fa-bars' : 'fa-times');
icon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
}
};
mobileMenuBtn.addEventListener('click', toggleMenu);
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (isMenuOpen && !mobileMenu.contains(e.target) && !mobileMenuBtn.contains(e.target)) {
toggleMenu();
}
});
// Close menu when pressing escape
document.addEventListener('keydown', (e) => {
if (isMenuOpen && e.key === 'Escape') {
toggleMenu();
}
});
// Handle viewport changes
const mediaQuery = window.matchMedia('(min-width: 1024px)');
mediaQuery.addEventListener('change', (e) => {
if (e.matches && isMenuOpen) {
toggleMenu();
}
});
}
});
// User dropdown functionality is handled by Alpine.js in the template
// No additional JavaScript needed for dropdown functionality
// Handle flash messages
document.addEventListener('DOMContentLoaded', () => {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(alert => {
setTimeout(() => {
alert.style.opacity = '0';
setTimeout(() => alert.remove(), 300);
}, 5000);
});
});
// Initialize tooltips
document.addEventListener('DOMContentLoaded', () => {
const tooltips = document.querySelectorAll('[data-tooltip]');
tooltips.forEach(tooltip => {
tooltip.addEventListener('mouseenter', (e) => {
const text = e.target.getAttribute('data-tooltip');
const tooltipEl = document.createElement('div');
tooltipEl.className = 'absolute z-50 px-2 py-1 text-sm text-white bg-gray-900 rounded tooltip';
tooltipEl.textContent = text;
document.body.appendChild(tooltipEl);
const rect = e.target.getBoundingClientRect();
tooltipEl.style.top = rect.bottom + 5 + 'px';
tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px';
});
tooltip.addEventListener('mouseleave', () => {
const tooltips = document.querySelectorAll('.tooltip');
tooltips.forEach(t => t.remove());
});
});
});

View File

@@ -1,573 +0,0 @@
/**
* ThrillWiki Map Filters - Location Filtering Component
*
* This module handles filter panel interactions and updates maps via HTMX
* Supports location type filtering, geographic filtering, and real-time search
*/
class MapFilters {
constructor(formId, options = {}) {
this.formId = formId;
this.options = {
autoSubmit: true,
searchDelay: 500,
enableLocalStorage: true,
storageKey: 'thrillwiki_map_filters',
mapInstance: null,
htmxTarget: '#map-container',
htmxUrl: null,
...options
};
this.form = null;
this.searchTimeout = null;
this.currentFilters = {};
this.filterChips = [];
this.init();
}
/**
* Initialize the filter component
*/
init() {
this.form = document.getElementById(this.formId);
if (!this.form) {
console.error(`Filter form with ID '${this.formId}' not found`);
return;
}
this.setupFilterChips();
this.bindEvents();
this.loadSavedFilters();
this.initializeFilters();
}
/**
* Setup filter chip interactions
*/
setupFilterChips() {
this.filterChips = this.form.querySelectorAll('.filter-chip, .filter-pill');
this.filterChips.forEach(chip => {
const checkbox = chip.querySelector('input[type="checkbox"]');
if (checkbox) {
// Set initial state
this.updateChipState(chip, checkbox.checked);
// Bind click handler
chip.addEventListener('click', (e) => {
e.preventDefault();
this.toggleChip(chip, checkbox);
});
}
});
}
/**
* Toggle filter chip state
*/
toggleChip(chip, checkbox) {
checkbox.checked = !checkbox.checked;
this.updateChipState(chip, checkbox.checked);
// Trigger change event
this.handleFilterChange();
}
/**
* Update visual state of filter chip
*/
updateChipState(chip, isActive) {
if (isActive) {
chip.classList.add('active');
chip.classList.remove('inactive');
} else {
chip.classList.remove('active');
chip.classList.add('inactive');
}
}
/**
* Bind event handlers
*/
bindEvents() {
// Form submission
this.form.addEventListener('submit', (e) => {
if (this.options.autoSubmit) {
e.preventDefault();
this.submitFilters();
}
});
// Input changes (excluding search)
this.form.addEventListener('change', (e) => {
if (e.target.name !== 'q' && !e.target.closest('.no-auto-submit')) {
this.handleFilterChange();
}
});
// Search input with debouncing
const searchInput = this.form.querySelector('input[name="q"]');
if (searchInput) {
searchInput.addEventListener('input', () => {
this.handleSearchInput();
});
}
// Range inputs
const rangeInputs = this.form.querySelectorAll('input[type="range"]');
rangeInputs.forEach(input => {
input.addEventListener('input', (e) => {
this.updateRangeDisplay(e.target);
});
});
// Clear filters button
const clearButton = this.form.querySelector('[data-action="clear-filters"]');
if (clearButton) {
clearButton.addEventListener('click', (e) => {
e.preventDefault();
this.clearAllFilters();
});
}
// HTMX events
if (typeof htmx !== 'undefined') {
this.form.addEventListener('htmx:beforeRequest', () => {
this.showLoadingState(true);
});
this.form.addEventListener('htmx:afterRequest', (e) => {
this.showLoadingState(false);
if (e.detail.successful) {
this.onFiltersApplied(this.getCurrentFilters());
}
});
this.form.addEventListener('htmx:responseError', (e) => {
this.showLoadingState(false);
this.handleError(e.detail);
});
}
}
/**
* Handle search input with debouncing
*/
handleSearchInput() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.handleFilterChange();
}, this.options.searchDelay);
}
/**
* Handle filter changes
*/
handleFilterChange() {
const filters = this.getCurrentFilters();
this.currentFilters = filters;
if (this.options.autoSubmit) {
this.submitFilters();
}
// Save filters to localStorage
if (this.options.enableLocalStorage) {
this.saveFilters(filters);
}
// Update map if connected
if (this.options.mapInstance && this.options.mapInstance.updateFilters) {
this.options.mapInstance.updateFilters(filters);
}
// Trigger custom event
this.triggerFilterEvent('filterChange', filters);
}
/**
* Submit filters via HTMX or form submission
*/
submitFilters() {
if (typeof htmx !== 'undefined' && this.options.htmxUrl) {
// Use HTMX
const formData = new FormData(this.form);
const params = new URLSearchParams(formData);
htmx.ajax('GET', `${this.options.htmxUrl}?${params}`, {
target: this.options.htmxTarget,
swap: 'none'
});
} else {
// Regular form submission
this.form.submit();
}
}
/**
* Get current filter values
*/
getCurrentFilters() {
const formData = new FormData(this.form);
const filters = {};
for (let [key, value] of formData.entries()) {
if (value.trim() === '') continue;
if (filters[key]) {
if (Array.isArray(filters[key])) {
filters[key].push(value);
} else {
filters[key] = [filters[key], value];
}
} else {
filters[key] = value;
}
}
return filters;
}
/**
* Set filter values
*/
setFilters(filters) {
Object.entries(filters).forEach(([key, value]) => {
const elements = this.form.querySelectorAll(`[name="${key}"]`);
elements.forEach(element => {
if (element.type === 'checkbox' || element.type === 'radio') {
if (Array.isArray(value)) {
element.checked = value.includes(element.value);
} else {
element.checked = element.value === value;
}
// Update chip state if applicable
const chip = element.closest('.filter-chip, .filter-pill');
if (chip) {
this.updateChipState(chip, element.checked);
}
} else {
element.value = Array.isArray(value) ? value[0] : value;
// Update range display if applicable
if (element.type === 'range') {
this.updateRangeDisplay(element);
}
}
});
});
this.currentFilters = filters;
}
/**
* Clear all filters
*/
clearAllFilters() {
// Reset form
this.form.reset();
// Update all chip states
this.filterChips.forEach(chip => {
const checkbox = chip.querySelector('input[type="checkbox"]');
if (checkbox) {
this.updateChipState(chip, false);
}
});
// Update range displays
const rangeInputs = this.form.querySelectorAll('input[type="range"]');
rangeInputs.forEach(input => {
this.updateRangeDisplay(input);
});
// Clear saved filters
if (this.options.enableLocalStorage) {
localStorage.removeItem(this.options.storageKey);
}
// Submit cleared filters
this.handleFilterChange();
}
/**
* Update range input display
*/
updateRangeDisplay(rangeInput) {
const valueDisplay = document.getElementById(`${rangeInput.id}-value`) ||
document.getElementById(`${rangeInput.name}-value`);
if (valueDisplay) {
valueDisplay.textContent = rangeInput.value;
}
}
/**
* Load saved filters from localStorage
*/
loadSavedFilters() {
if (!this.options.enableLocalStorage) return;
try {
const saved = localStorage.getItem(this.options.storageKey);
if (saved) {
const filters = JSON.parse(saved);
this.setFilters(filters);
}
} catch (error) {
console.warn('Failed to load saved filters:', error);
}
}
/**
* Save filters to localStorage
*/
saveFilters(filters) {
if (!this.options.enableLocalStorage) return;
try {
localStorage.setItem(this.options.storageKey, JSON.stringify(filters));
} catch (error) {
console.warn('Failed to save filters:', error);
}
}
/**
* Initialize filters from URL parameters or defaults
*/
initializeFilters() {
// Check for URL parameters
const urlParams = new URLSearchParams(window.location.search);
const urlFilters = {};
for (let [key, value] of urlParams.entries()) {
if (urlFilters[key]) {
if (Array.isArray(urlFilters[key])) {
urlFilters[key].push(value);
} else {
urlFilters[key] = [urlFilters[key], value];
}
} else {
urlFilters[key] = value;
}
}
if (Object.keys(urlFilters).length > 0) {
this.setFilters(urlFilters);
}
// Emit initial filter state
this.triggerFilterEvent('filterInit', this.getCurrentFilters());
}
/**
* Show/hide loading state
*/
showLoadingState(show) {
const loadingIndicators = this.form.querySelectorAll('.filter-loading, .htmx-indicator');
loadingIndicators.forEach(indicator => {
indicator.style.display = show ? 'block' : 'none';
});
// Disable form during loading
const inputs = this.form.querySelectorAll('input, select, button');
inputs.forEach(input => {
input.disabled = show;
});
}
/**
* Handle errors
*/
handleError(detail) {
console.error('Filter request failed:', detail);
// Show user-friendly error message
this.showMessage('Failed to apply filters. Please try again.', 'error');
}
/**
* Show message to user
*/
showMessage(message, type = 'info') {
// Create or update message element
let messageEl = this.form.querySelector('.filter-message');
if (!messageEl) {
messageEl = document.createElement('div');
messageEl.className = 'filter-message';
this.form.insertBefore(messageEl, this.form.firstChild);
}
messageEl.textContent = message;
messageEl.className = `filter-message filter-message-${type}`;
// Auto-hide after delay
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.remove();
}
}, 5000);
}
/**
* Callback for when filters are successfully applied
*/
onFiltersApplied(filters) {
this.triggerFilterEvent('filterApplied', filters);
}
/**
* Trigger custom events
*/
triggerFilterEvent(eventName, data) {
const event = new CustomEvent(eventName, {
detail: data
});
this.form.dispatchEvent(event);
}
/**
* Connect to a map instance
*/
connectToMap(mapInstance) {
this.options.mapInstance = mapInstance;
// Listen to map events
if (mapInstance.on) {
mapInstance.on('boundsChange', (bounds) => {
// Could update location-based filters here
});
}
}
/**
* Export current filters as URL parameters
*/
getFilterUrl(baseUrl = window.location.pathname) {
const filters = this.getCurrentFilters();
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => params.append(key, v));
} else {
params.append(key, value);
}
});
return params.toString() ? `${baseUrl}?${params}` : baseUrl;
}
/**
* Update URL with current filters (without page reload)
*/
updateUrl() {
const url = this.getFilterUrl();
if (window.history && window.history.pushState) {
window.history.pushState(null, '', url);
}
}
/**
* Get filter summary for display
*/
getFilterSummary() {
const filters = this.getCurrentFilters();
const summary = [];
// Location types
if (filters.types) {
const types = Array.isArray(filters.types) ? filters.types : [filters.types];
summary.push(`Types: ${types.join(', ')}`);
}
// Geographic filters
if (filters.country) summary.push(`Country: ${filters.country}`);
if (filters.state) summary.push(`State: ${filters.state}`);
if (filters.city) summary.push(`City: ${filters.city}`);
// Search query
if (filters.q) summary.push(`Search: "${filters.q}"`);
// Radius
if (filters.radius) summary.push(`Within ${filters.radius} miles`);
return summary.length > 0 ? summary.join(' • ') : 'No filters applied';
}
/**
* Reset to default filters
*/
resetToDefaults() {
const defaults = {
types: ['park'],
cluster: 'true'
};
this.setFilters(defaults);
this.handleFilterChange();
}
/**
* Destroy the filter component
*/
destroy() {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Remove event listeners would go here if we stored references
// For now, rely on garbage collection
}
}
// Auto-initialize filter forms
document.addEventListener('DOMContentLoaded', function() {
// Initialize map filters form
const mapFiltersForm = document.getElementById('map-filters');
if (mapFiltersForm) {
window.mapFilters = new MapFilters('map-filters', {
htmxUrl: mapFiltersForm.getAttribute('hx-get'),
htmxTarget: mapFiltersForm.getAttribute('hx-target') || '#map-container'
});
// Connect to map instance if available
if (window.thrillwikiMap) {
window.mapFilters.connectToMap(window.thrillwikiMap);
}
}
// Initialize other filter forms with data attributes
const filterForms = document.querySelectorAll('[data-filter-form]');
filterForms.forEach(form => {
const options = {};
// Parse data attributes
Object.keys(form.dataset).forEach(key => {
if (key.startsWith('filter')) {
const optionKey = key.replace('filter', '').toLowerCase();
let value = form.dataset[key];
// Parse boolean values
if (value === 'true') value = true;
else if (value === 'false') value = false;
options[optionKey] = value;
}
});
new MapFilters(form.id, options);
});
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = MapFilters;
} else {
window.MapFilters = MapFilters;
}

View File

@@ -1,553 +0,0 @@
/**
* ThrillWiki Map Integration - Master Integration Script
*
* This module coordinates all map components, handles initialization order,
* manages component communication, and provides a unified API
*/
class MapIntegration {
constructor(options = {}) {
this.options = {
autoInit: true,
enableLogging: true,
enablePerformanceMonitoring: true,
initTimeout: 10000,
retryAttempts: 3,
components: {
maps: true,
filters: true,
roadtrip: true,
geolocation: true,
markers: true,
htmx: true,
mobileTouch: true,
darkMode: true
},
...options
};
this.components = {};
this.initOrder = [
'darkMode',
'mobileTouch',
'maps',
'markers',
'filters',
'geolocation',
'htmx',
'roadtrip'
];
this.initialized = false;
this.initStartTime = null;
this.errors = [];
if (this.options.autoInit) {
this.init();
}
}
/**
* Initialize all map components
*/
async init() {
this.initStartTime = performance.now();
this.log('Starting map integration initialization...');
try {
// Wait for DOM to be ready
await this.waitForDOM();
// Initialize components in order
await this.initializeComponents();
// Connect components
this.connectComponents();
// Setup global event handlers
this.setupGlobalHandlers();
// Verify integration
this.verifyIntegration();
this.initialized = true;
this.logPerformance();
this.log('Map integration initialized successfully');
// Emit ready event
this.emitEvent('mapIntegrationReady', {
components: Object.keys(this.components),
initTime: performance.now() - this.initStartTime
});
} catch (error) {
this.handleInitError(error);
}
}
/**
* Wait for DOM to be ready
*/
waitForDOM() {
return new Promise((resolve) => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', resolve);
} else {
resolve();
}
});
}
/**
* Initialize components in the correct order
*/
async initializeComponents() {
for (const componentName of this.initOrder) {
if (!this.options.components[componentName]) {
this.log(`Skipping ${componentName} (disabled)`);
continue;
}
try {
await this.initializeComponent(componentName);
this.log(`${componentName} initialized`);
} catch (error) {
this.error(`✗ Failed to initialize ${componentName}:`, error);
this.errors.push({ component: componentName, error });
}
}
}
/**
* Initialize individual component
*/
async initializeComponent(componentName) {
switch (componentName) {
case 'darkMode':
if (window.DarkModeMaps) {
this.components.darkMode = window.darkModeMaps || new DarkModeMaps();
}
break;
case 'mobileTouch':
if (window.MobileTouchSupport) {
this.components.mobileTouch = window.mobileTouchSupport || new MobileTouchSupport();
}
break;
case 'maps':
// Look for existing map instances or create new ones
if (window.thrillwikiMap) {
this.components.maps = window.thrillwikiMap;
} else if (window.ThrillWikiMap) {
const mapContainer = document.getElementById('map-container');
if (mapContainer) {
this.components.maps = new ThrillWikiMap('map-container');
window.thrillwikiMap = this.components.maps;
}
}
break;
case 'markers':
if (window.MapMarkers && this.components.maps) {
this.components.markers = window.mapMarkers || new MapMarkers(this.components.maps);
}
break;
case 'filters':
if (window.MapFilters) {
const filterForm = document.getElementById('map-filters');
if (filterForm) {
this.components.filters = window.mapFilters || new MapFilters('map-filters');
}
}
break;
case 'geolocation':
if (window.UserLocation) {
this.components.geolocation = window.userLocation || new UserLocation();
}
break;
case 'htmx':
if (window.HTMXMapIntegration && typeof htmx !== 'undefined') {
this.components.htmx = window.htmxMapIntegration || new HTMXMapIntegration();
}
break;
case 'roadtrip':
if (window.RoadTripPlanner) {
const roadtripContainer = document.getElementById('roadtrip-planner');
if (roadtripContainer) {
this.components.roadtrip = window.roadTripPlanner || new RoadTripPlanner('roadtrip-planner');
}
}
break;
}
}
/**
* Connect components together
*/
connectComponents() {
this.log('Connecting components...');
// Connect maps to other components
if (this.components.maps) {
// Connect to dark mode
if (this.components.darkMode) {
this.components.darkMode.registerMapInstance(this.components.maps);
}
// Connect to mobile touch
if (this.components.mobileTouch) {
this.components.mobileTouch.registerMapInstance(this.components.maps);
}
// Connect to geolocation
if (this.components.geolocation) {
this.components.geolocation.connectToMap(this.components.maps);
}
// Connect to road trip planner
if (this.components.roadtrip) {
this.components.roadtrip.connectToMap(this.components.maps);
}
}
// Connect filters to other components
if (this.components.filters) {
// Connect to maps
if (this.components.maps) {
this.components.filters.connectToMap(this.components.maps);
}
// Connect to HTMX
if (this.components.htmx) {
this.components.htmx.connectToFilter(this.components.filters);
}
}
// Connect HTMX to maps
if (this.components.htmx && this.components.maps) {
this.components.htmx.connectToMap(this.components.maps);
}
}
/**
* Setup global event handlers
*/
setupGlobalHandlers() {
// Handle global map events
document.addEventListener('mapDataUpdate', (e) => {
this.handleMapDataUpdate(e.detail);
});
// Handle filter changes
document.addEventListener('filterChange', (e) => {
this.handleFilterChange(e.detail);
});
// Handle theme changes
document.addEventListener('themeChanged', (e) => {
this.handleThemeChange(e.detail);
});
// Handle orientation changes
document.addEventListener('orientationChanged', (e) => {
this.handleOrientationChange(e.detail);
});
// Handle visibility changes for performance
document.addEventListener('visibilitychange', () => {
this.handleVisibilityChange();
});
// Handle errors
window.addEventListener('error', (e) => {
if (this.isMapRelatedError(e)) {
this.handleGlobalError(e);
}
});
}
/**
* Handle map data updates
*/
handleMapDataUpdate(data) {
if (this.components.maps) {
this.components.maps.updateMarkers(data);
}
}
/**
* Handle filter changes
*/
handleFilterChange(filters) {
if (this.components.maps) {
this.components.maps.updateFilters(filters);
}
}
/**
* Handle theme changes
*/
handleThemeChange(themeData) {
// All components should already be listening for this
// Just log for monitoring
this.log(`Theme changed to ${themeData.newTheme}`);
}
/**
* Handle orientation changes
*/
handleOrientationChange(orientationData) {
// Invalidate map sizes after orientation change
if (this.components.maps) {
setTimeout(() => {
this.components.maps.invalidateSize();
}, 300);
}
}
/**
* Handle visibility changes
*/
handleVisibilityChange() {
const isHidden = document.hidden;
// Pause/resume location watching
if (this.components.geolocation) {
if (isHidden) {
this.components.geolocation.stopWatching();
} else if (this.components.geolocation.options.watchPosition) {
this.components.geolocation.startWatching();
}
}
}
/**
* Check if error is map-related
*/
isMapRelatedError(error) {
const mapKeywords = ['leaflet', 'map', 'marker', 'tile', 'geolocation', 'htmx'];
const errorMessage = error.message ? error.message.toLowerCase() : '';
const errorStack = error.error && error.error.stack ? error.error.stack.toLowerCase() : '';
return mapKeywords.some(keyword =>
errorMessage.includes(keyword) || errorStack.includes(keyword)
);
}
/**
* Handle global errors
*/
handleGlobalError(error) {
this.error('Global map error:', error);
this.errors.push({ type: 'global', error });
// Emit error event
this.emitEvent('mapError', { error, timestamp: Date.now() });
}
/**
* Verify integration is working
*/
verifyIntegration() {
const issues = [];
// Check required components
if (this.options.components.maps && !this.components.maps) {
issues.push('Maps component not initialized');
}
// Check component connections
if (this.components.maps && this.components.darkMode) {
if (!this.components.darkMode.mapInstances.has(this.components.maps)) {
issues.push('Maps not connected to dark mode');
}
}
// Check DOM elements
const mapContainer = document.getElementById('map-container');
if (this.components.maps && !mapContainer) {
issues.push('Map container not found in DOM');
}
if (issues.length > 0) {
this.warn('Integration issues found:', issues);
}
return issues.length === 0;
}
/**
* Handle initialization errors
*/
handleInitError(error) {
this.error('Map integration initialization failed:', error);
// Emit error event
this.emitEvent('mapIntegrationError', {
error,
errors: this.errors,
timestamp: Date.now()
});
// Try to initialize what we can
this.attemptPartialInit();
}
/**
* Attempt partial initialization
*/
attemptPartialInit() {
this.log('Attempting partial initialization...');
// Try to initialize at least the core map
if (!this.components.maps && window.ThrillWikiMap) {
try {
const mapContainer = document.getElementById('map-container');
if (mapContainer) {
this.components.maps = new ThrillWikiMap('map-container');
this.log('✓ Core map initialized in fallback mode');
}
} catch (error) {
this.error('✗ Fallback map initialization failed:', error);
}
}
}
/**
* Get component by name
*/
getComponent(name) {
return this.components[name] || null;
}
/**
* Get all components
*/
getAllComponents() {
return { ...this.components };
}
/**
* Check if integration is ready
*/
isReady() {
return this.initialized;
}
/**
* Get initialization status
*/
getStatus() {
return {
initialized: this.initialized,
components: Object.keys(this.components),
errors: this.errors,
initTime: this.initStartTime ? performance.now() - this.initStartTime : null
};
}
/**
* Emit custom event
*/
emitEvent(eventName, detail) {
const event = new CustomEvent(eventName, { detail });
document.dispatchEvent(event);
}
/**
* Log performance metrics
*/
logPerformance() {
if (!this.options.enablePerformanceMonitoring) return;
const initTime = performance.now() - this.initStartTime;
const componentCount = Object.keys(this.components).length;
this.log(`Performance: ${initTime.toFixed(2)}ms to initialize ${componentCount} components`);
// Send to analytics if available
if (typeof gtag !== 'undefined') {
gtag('event', 'map_integration_performance', {
event_category: 'performance',
value: Math.round(initTime),
custom_map: {
component_count: componentCount,
errors: this.errors.length
}
});
}
}
/**
* Logging methods
*/
log(message, ...args) {
if (this.options.enableLogging) {
console.log(`[MapIntegration] ${message}`, ...args);
}
}
warn(message, ...args) {
if (this.options.enableLogging) {
console.warn(`[MapIntegration] ${message}`, ...args);
}
}
error(message, ...args) {
if (this.options.enableLogging) {
console.error(`[MapIntegration] ${message}`, ...args);
}
}
/**
* Destroy integration
*/
destroy() {
// Destroy all components
Object.values(this.components).forEach(component => {
if (component && typeof component.destroy === 'function') {
try {
component.destroy();
} catch (error) {
this.error('Error destroying component:', error);
}
}
});
this.components = {};
this.initialized = false;
this.log('Map integration destroyed');
}
}
// Auto-initialize map integration
let mapIntegration;
document.addEventListener('DOMContentLoaded', function() {
// Only initialize if we have map-related elements
const hasMapElements = document.querySelector('#map-container, .map-container, [data-map], [data-roadtrip]');
if (hasMapElements) {
mapIntegration = new MapIntegration();
window.mapIntegration = mapIntegration;
}
});
// Global API for external access
window.ThrillWikiMaps = {
getIntegration: () => mapIntegration,
isReady: () => mapIntegration && mapIntegration.isReady(),
getComponent: (name) => mapIntegration ? mapIntegration.getComponent(name) : null,
getStatus: () => mapIntegration ? mapIntegration.getStatus() : { initialized: false }
};
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = MapIntegration;
} else {
window.MapIntegration = MapIntegration;
}

View File

@@ -1,850 +0,0 @@
/**
* ThrillWiki Map Markers - Custom Marker Icons and Rich Popup System
*
* This module handles custom marker icons for different location types,
* rich popup content with location details, and performance optimization
*/
class MapMarkers {
constructor(mapInstance, options = {}) {
this.mapInstance = mapInstance;
this.options = {
enableClustering: true,
clusterDistance: 50,
enableCustomIcons: true,
enableRichPopups: true,
enableMarkerAnimation: true,
popupMaxWidth: 300,
iconTheme: 'modern', // 'modern', 'classic', 'emoji'
apiEndpoints: {
details: '/api/map/location-detail/',
media: '/api/media/'
},
...options
};
this.markerStyles = this.initializeMarkerStyles();
this.iconCache = new Map();
this.popupCache = new Map();
this.activePopup = null;
this.init();
}
/**
* Initialize the marker system
*/
init() {
this.setupMarkerStyles();
this.setupClusterStyles();
}
/**
* Initialize marker style definitions
*/
initializeMarkerStyles() {
return {
park: {
operating: {
color: '#10B981',
emoji: '🎢',
icon: 'fas fa-tree',
size: 'large'
},
closed_temp: {
color: '#F59E0B',
emoji: '🚧',
icon: 'fas fa-clock',
size: 'medium'
},
closed_perm: {
color: '#EF4444',
emoji: '❌',
icon: 'fas fa-times-circle',
size: 'medium'
},
under_construction: {
color: '#8B5CF6',
emoji: '🏗️',
icon: 'fas fa-hard-hat',
size: 'medium'
},
demolished: {
color: '#6B7280',
emoji: '🏚️',
icon: 'fas fa-ban',
size: 'small'
}
},
ride: {
operating: {
color: '#3B82F6',
emoji: '🎠',
icon: 'fas fa-rocket',
size: 'medium'
},
closed_temp: {
color: '#F59E0B',
emoji: '⏸️',
icon: 'fas fa-pause-circle',
size: 'small'
},
closed_perm: {
color: '#EF4444',
emoji: '❌',
icon: 'fas fa-times-circle',
size: 'small'
},
under_construction: {
color: '#8B5CF6',
emoji: '🔨',
icon: 'fas fa-tools',
size: 'small'
},
removed: {
color: '#6B7280',
emoji: '💔',
icon: 'fas fa-trash',
size: 'small'
}
},
company: {
manufacturer: {
color: '#8B5CF6',
emoji: '🏭',
icon: 'fas fa-industry',
size: 'medium'
},
operator: {
color: '#059669',
emoji: '🏢',
icon: 'fas fa-building',
size: 'medium'
},
designer: {
color: '#DC2626',
emoji: '🎨',
icon: 'fas fa-pencil-ruler',
size: 'medium'
}
},
user: {
current: {
color: '#3B82F6',
emoji: '📍',
icon: 'fas fa-crosshairs',
size: 'medium'
}
}
};
}
/**
* Setup marker styles in CSS
*/
setupMarkerStyles() {
if (document.getElementById('map-marker-styles')) return;
const styles = `
<style id="map-marker-styles">
.location-marker {
background: transparent;
border: none;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: transform 0.2s ease;
}
.location-marker:hover {
transform: scale(1.1);
z-index: 1000;
}
.location-marker-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
color: white;
font-weight: bold;
font-size: 12px;
border: 3px solid white;
position: relative;
}
.location-marker-inner::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid inherit;
}
.location-marker.size-small .location-marker-inner {
width: 24px;
height: 24px;
font-size: 10px;
}
.location-marker.size-medium .location-marker-inner {
width: 32px;
height: 32px;
font-size: 12px;
}
.location-marker.size-large .location-marker-inner {
width: 40px;
height: 40px;
font-size: 14px;
}
.location-marker-emoji {
font-size: 1.2em;
line-height: 1;
}
.location-marker-icon {
font-size: 0.9em;
}
/* Cluster markers */
.cluster-marker {
background: transparent;
border: none;
}
.cluster-marker-inner {
background: #3B82F6;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: transform 0.2s ease;
}
.cluster-marker:hover .cluster-marker-inner {
transform: scale(1.1);
}
.cluster-marker-small .cluster-marker-inner {
width: 32px;
height: 32px;
font-size: 12px;
}
.cluster-marker-medium .cluster-marker-inner {
width: 40px;
height: 40px;
font-size: 14px;
background: #059669;
}
.cluster-marker-large .cluster-marker-inner {
width: 48px;
height: 48px;
font-size: 16px;
background: #DC2626;
}
/* Popup styles */
.location-popup {
min-width: 200px;
max-width: 300px;
}
.popup-header {
margin-bottom: 10px;
}
.popup-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 5px 0;
color: #1F2937;
}
.popup-subtitle {
font-size: 12px;
color: #6B7280;
margin: 0;
}
.popup-content {
margin-bottom: 12px;
}
.popup-detail {
display: flex;
align-items: center;
margin: 4px 0;
font-size: 13px;
color: #374151;
}
.popup-detail i {
width: 16px;
margin-right: 6px;
color: #6B7280;
}
.popup-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.popup-btn {
padding: 4px 8px;
font-size: 12px;
border-radius: 4px;
border: none;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
transition: background-color 0.2s;
}
.popup-btn-primary {
background: #3B82F6;
color: white;
}
.popup-btn-primary:hover {
background: #2563EB;
}
.popup-btn-secondary {
background: #F3F4F6;
color: #374151;
}
.popup-btn-secondary:hover {
background: #E5E7EB;
}
.popup-image {
width: 100%;
max-height: 120px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
}
.popup-status {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.popup-status.operating {
background: #D1FAE5;
color: #065F46;
}
.popup-status.closed {
background: #FEE2E2;
color: #991B1B;
}
.popup-status.construction {
background: #EDE9FE;
color: #5B21B6;
}
/* Dark mode styles */
.dark .popup-title {
color: #F9FAFB;
}
.dark .popup-detail {
color: #D1D5DB;
}
.dark .popup-btn-secondary {
background: #374151;
color: #D1D5DB;
}
.dark .popup-btn-secondary:hover {
background: #4B5563;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
/**
* Setup cluster marker styles
*/
setupClusterStyles() {
// Additional cluster-specific styles if needed
}
/**
* Create a location marker
*/
createLocationMarker(location) {
const iconData = this.getMarkerIconData(location);
const icon = this.createCustomIcon(iconData, location);
const marker = L.marker([location.latitude, location.longitude], {
icon: icon,
locationData: location,
riseOnHover: true
});
// Create popup
if (this.options.enableRichPopups) {
const popupContent = this.createPopupContent(location);
marker.bindPopup(popupContent, {
maxWidth: this.options.popupMaxWidth,
className: 'location-popup-container'
});
}
// Add click handler
marker.on('click', (e) => {
this.handleMarkerClick(marker, location);
});
// Add hover effects if animation is enabled
if (this.options.enableMarkerAnimation) {
marker.on('mouseover', () => {
const iconElement = marker.getElement();
if (iconElement) {
iconElement.style.transform = 'scale(1.1)';
iconElement.style.zIndex = '1000';
}
});
marker.on('mouseout', () => {
const iconElement = marker.getElement();
if (iconElement) {
iconElement.style.transform = 'scale(1)';
iconElement.style.zIndex = '';
}
});
}
return marker;
}
/**
* Get marker icon data based on location type and status
*/
getMarkerIconData(location) {
const type = location.type || 'generic';
const status = location.status || 'operating';
// Get style data
const typeStyles = this.markerStyles[type];
if (!typeStyles) {
return this.markerStyles.park.operating;
}
const statusStyle = typeStyles[status.toLowerCase()];
if (!statusStyle) {
// Fallback to first available status for this type
const firstStatus = Object.keys(typeStyles)[0];
return typeStyles[firstStatus];
}
return statusStyle;
}
/**
* Create custom icon
*/
createCustomIcon(iconData, location) {
const cacheKey = `${location.type}-${location.status}-${this.options.iconTheme}`;
if (this.iconCache.has(cacheKey)) {
return this.iconCache.get(cacheKey);
}
let iconHtml;
switch (this.options.iconTheme) {
case 'emoji':
iconHtml = `<span class="location-marker-emoji">${iconData.emoji}</span>`;
break;
case 'classic':
iconHtml = `<i class="location-marker-icon ${iconData.icon}"></i>`;
break;
case 'modern':
default:
iconHtml = location.featured_image ?
`<img src="${location.featured_image}" alt="${location.name}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">` :
`<i class="location-marker-icon ${iconData.icon}"></i>`;
break;
}
const sizeClass = iconData.size || 'medium';
const size = sizeClass === 'small' ? 24 : sizeClass === 'large' ? 40 : 32;
const icon = L.divIcon({
className: `location-marker size-${sizeClass}`,
html: `<div class="location-marker-inner" style="background-color: ${iconData.color}">${iconHtml}</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2) - 8]
});
this.iconCache.set(cacheKey, icon);
return icon;
}
/**
* Create rich popup content
*/
createPopupContent(location) {
const cacheKey = `popup-${location.type}-${location.id}`;
if (this.popupCache.has(cacheKey)) {
return this.popupCache.get(cacheKey);
}
const statusClass = this.getStatusClass(location.status);
const content = `
<div class="location-popup">
${location.featured_image ? `
<img src="${location.featured_image}" alt="${location.name}" class="popup-image">
` : ''}
<div class="popup-header">
<h3 class="popup-title">${this.escapeHtml(location.name)}</h3>
${location.type ? `<p class="popup-subtitle">${this.capitalizeFirst(location.type)}</p>` : ''}
${location.status ? `<span class="popup-status ${statusClass}">${this.formatStatus(location.status)}</span>` : ''}
</div>
<div class="popup-content">
${this.createPopupDetails(location)}
</div>
<div class="popup-actions">
${this.createPopupActions(location)}
</div>
</div>
`;
this.popupCache.set(cacheKey, content);
return content;
}
/**
* Create popup detail items
*/
createPopupDetails(location) {
const details = [];
if (location.formatted_location) {
details.push(`
<div class="popup-detail">
<i class="fas fa-map-marker-alt"></i>
<span>${this.escapeHtml(location.formatted_location)}</span>
</div>
`);
}
if (location.operator) {
details.push(`
<div class="popup-detail">
<i class="fas fa-building"></i>
<span>${this.escapeHtml(location.operator)}</span>
</div>
`);
}
if (location.ride_count && location.ride_count > 0) {
details.push(`
<div class="popup-detail">
<i class="fas fa-rocket"></i>
<span>${location.ride_count} ride${location.ride_count === 1 ? '' : 's'}</span>
</div>
`);
}
if (location.opened_date) {
details.push(`
<div class="popup-detail">
<i class="fas fa-calendar"></i>
<span>Opened ${this.formatDate(location.opened_date)}</span>
</div>
`);
}
if (location.manufacturer) {
details.push(`
<div class="popup-detail">
<i class="fas fa-industry"></i>
<span>${this.escapeHtml(location.manufacturer)}</span>
</div>
`);
}
if (location.designer) {
details.push(`
<div class="popup-detail">
<i class="fas fa-pencil-ruler"></i>
<span>${this.escapeHtml(location.designer)}</span>
</div>
`);
}
return details.join('');
}
/**
* Create popup action buttons
*/
createPopupActions(location) {
const actions = [];
// View details button
actions.push(`
<button onclick="mapMarkers.showLocationDetails('${location.type}', ${location.id})"
class="popup-btn popup-btn-primary">
<i class="fas fa-eye"></i>
View Details
</button>
`);
// Add to road trip (for parks)
if (location.type === 'park' && window.roadTripPlanner) {
actions.push(`
<button onclick="roadTripPlanner.addPark(${location.id})"
class="popup-btn popup-btn-secondary">
<i class="fas fa-route"></i>
Add to Trip
</button>
`);
}
// Get directions
if (location.latitude && location.longitude) {
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${location.latitude},${location.longitude}`;
actions.push(`
<a href="${mapsUrl}" target="_blank"
class="popup-btn popup-btn-secondary">
<i class="fas fa-directions"></i>
Directions
</a>
`);
}
return actions.join('');
}
/**
* Handle marker click events
*/
handleMarkerClick(marker, location) {
this.activePopup = marker.getPopup();
// Load additional data if needed
this.loadLocationDetails(location);
// Track click event
if (typeof gtag !== 'undefined') {
gtag('event', 'marker_click', {
event_category: 'map',
event_label: `${location.type}:${location.id}`,
custom_map: {
location_type: location.type,
location_name: location.name
}
});
}
}
/**
* Load additional location details
*/
async loadLocationDetails(location) {
try {
const response = await fetch(`${this.options.apiEndpoints.details}${location.type}/${location.id}/`);
const data = await response.json();
if (data.status === 'success') {
// Update popup with additional details if popup is still open
if (this.activePopup && this.activePopup.isOpen()) {
const updatedContent = this.createPopupContent(data.data);
this.activePopup.setContent(updatedContent);
}
}
} catch (error) {
console.error('Failed to load location details:', error);
}
}
/**
* Show location details modal/page
*/
showLocationDetails(type, id) {
const url = `/${type}/${id}/`;
if (typeof htmx !== 'undefined') {
htmx.ajax('GET', url, {
target: '#location-modal',
swap: 'innerHTML'
}).then(() => {
const modal = document.getElementById('location-modal');
if (modal) {
modal.classList.remove('hidden');
}
});
} else {
window.location.href = url;
}
}
/**
* Get CSS class for status
*/
getStatusClass(status) {
if (!status) return '';
const statusLower = status.toLowerCase();
if (statusLower.includes('operating') || statusLower.includes('open')) {
return 'operating';
} else if (statusLower.includes('closed') || statusLower.includes('temp')) {
return 'closed';
} else if (statusLower.includes('construction') || statusLower.includes('building')) {
return 'construction';
}
return '';
}
/**
* Format status for display
*/
formatStatus(status) {
return status.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
}
/**
* Format date for display
*/
formatDate(dateString) {
try {
const date = new Date(dateString);
return date.getFullYear();
} catch (error) {
return dateString;
}
}
/**
* Capitalize first letter
*/
capitalizeFirst(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Escape HTML
*/
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* Create cluster marker
*/
createClusterMarker(cluster) {
const count = cluster.getChildCount();
let sizeClass = 'small';
if (count > 100) sizeClass = 'large';
else if (count > 10) sizeClass = 'medium';
return L.divIcon({
html: `<div class="cluster-marker-inner">${count}</div>`,
className: `cluster-marker cluster-marker-${sizeClass}`,
iconSize: L.point(sizeClass === 'small' ? 32 : sizeClass === 'medium' ? 40 : 48,
sizeClass === 'small' ? 32 : sizeClass === 'medium' ? 40 : 48)
});
}
/**
* Update marker theme
*/
setIconTheme(theme) {
this.options.iconTheme = theme;
this.iconCache.clear();
// Re-render all markers if map instance is available
if (this.mapInstance && this.mapInstance.markers) {
// This would need to be implemented in the main map class
console.log(`Icon theme changed to: ${theme}`);
}
}
/**
* Clear popup cache
*/
clearPopupCache() {
this.popupCache.clear();
}
/**
* Clear icon cache
*/
clearIconCache() {
this.iconCache.clear();
}
/**
* Get marker statistics
*/
getMarkerStats() {
return {
iconCacheSize: this.iconCache.size,
popupCacheSize: this.popupCache.size,
iconTheme: this.options.iconTheme
};
}
}
// Auto-initialize with map instance if available
document.addEventListener('DOMContentLoaded', function() {
if (window.thrillwikiMap) {
window.mapMarkers = new MapMarkers(window.thrillwikiMap);
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = MapMarkers;
} else {
window.MapMarkers = MapMarkers;
}

View File

@@ -1,656 +0,0 @@
/**
* ThrillWiki Maps - Core Map Functionality
*
* This module provides the main map functionality for ThrillWiki using Leaflet.js
* Includes clustering, filtering, dark mode support, and HTMX integration
*/
class ThrillWikiMap {
constructor(containerId, options = {}) {
this.containerId = containerId;
this.options = {
center: [39.8283, -98.5795], // Center of USA
zoom: 4,
minZoom: 2,
maxZoom: 18,
enableClustering: true,
enableDarkMode: true,
enableGeolocation: false,
apiEndpoints: {
locations: '/api/map/locations/',
details: '/api/map/location-detail/'
},
...options
};
this.map = null;
this.markers = null;
this.currentData = [];
this.userLocation = null;
this.currentTileLayer = null;
this.boundsUpdateTimeout = null;
// Event handlers
this.eventHandlers = {
locationClick: [],
boundsChange: [],
dataLoad: []
};
this.init();
}
/**
* Initialize the map
*/
init() {
const container = document.getElementById(this.containerId);
if (!container) {
console.error(`Map container with ID '${this.containerId}' not found`);
return;
}
try {
this.initializeMap();
this.setupTileLayers();
this.setupClustering();
this.bindEvents();
this.loadInitialData();
} catch (error) {
console.error('Failed to initialize map:', error);
}
}
/**
* Initialize the Leaflet map instance
*/
initializeMap() {
this.map = L.map(this.containerId, {
center: this.options.center,
zoom: this.options.zoom,
minZoom: this.options.minZoom,
maxZoom: this.options.maxZoom,
zoomControl: false,
attributionControl: false
});
// Add custom zoom control
L.control.zoom({
position: 'bottomright'
}).addTo(this.map);
// Add attribution control
L.control.attribution({
position: 'bottomleft',
prefix: false
}).addTo(this.map);
}
/**
* Setup tile layers with dark mode support
*/
setupTileLayers() {
this.tileLayers = {
light: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
className: 'map-tiles-light'
}),
dark: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO',
className: 'map-tiles-dark'
}),
satellite: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: '© Esri, DigitalGlobe, GeoEye, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community',
className: 'map-tiles-satellite'
})
};
// Set initial tile layer based on theme
this.updateTileLayer();
// Listen for theme changes if dark mode is enabled
if (this.options.enableDarkMode) {
this.observeThemeChanges();
}
}
/**
* Setup marker clustering
*/
setupClustering() {
if (this.options.enableClustering) {
this.markers = L.markerClusterGroup({
chunkedLoading: true,
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
iconCreateFunction: (cluster) => {
const count = cluster.getChildCount();
let className = 'cluster-marker-small';
if (count > 100) className = 'cluster-marker-large';
else if (count > 10) className = 'cluster-marker-medium';
return L.divIcon({
html: `<div class="cluster-marker-inner">${count}</div>`,
className: `cluster-marker ${className}`,
iconSize: L.point(40, 40)
});
}
});
} else {
this.markers = L.layerGroup();
}
this.map.addLayer(this.markers);
}
/**
* Bind map events
*/
bindEvents() {
// Map movement events
this.map.on('moveend zoomend', () => {
this.handleBoundsChange();
});
// Marker click events
this.markers.on('click', (e) => {
if (e.layer.options && e.layer.options.locationData) {
this.handleLocationClick(e.layer.options.locationData);
}
});
// Custom event handlers
this.map.on('locationfound', (e) => {
this.handleLocationFound(e);
});
this.map.on('locationerror', (e) => {
this.handleLocationError(e);
});
}
/**
* Observe theme changes for automatic tile layer switching
*/
observeThemeChanges() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
this.updateTileLayer();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
/**
* Update tile layer based on current theme and settings
*/
updateTileLayer() {
// Remove current tile layer
if (this.currentTileLayer) {
this.map.removeLayer(this.currentTileLayer);
}
// Determine which layer to use
let layerType = 'light';
if (document.documentElement.classList.contains('dark')) {
layerType = 'dark';
}
// Check for satellite mode toggle
const satelliteToggle = document.querySelector('input[name="satellite"]');
if (satelliteToggle && satelliteToggle.checked) {
layerType = 'satellite';
}
// Add the appropriate tile layer
this.currentTileLayer = this.tileLayers[layerType];
this.map.addLayer(this.currentTileLayer);
}
/**
* Load initial map data
*/
async loadInitialData() {
const bounds = this.map.getBounds();
await this.loadLocations(bounds, {});
}
/**
* Load locations with optional bounds and filters
*/
async loadLocations(bounds = null, filters = {}) {
try {
this.showLoading(true);
const params = new URLSearchParams();
// Add bounds if provided
if (bounds) {
params.append('north', bounds.getNorth());
params.append('south', bounds.getSouth());
params.append('east', bounds.getEast());
params.append('west', bounds.getWest());
}
// Add zoom level
params.append('zoom', this.map.getZoom());
// Add filters
Object.entries(filters).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => params.append(key, v));
} else if (value !== null && value !== undefined && value !== '') {
params.append(key, value);
}
});
const response = await fetch(`${this.options.apiEndpoints.locations}?${params}`);
const data = await response.json();
if (data.status === 'success') {
this.updateMarkers(data.data);
this.triggerEvent('dataLoad', data.data);
} else {
console.error('Map data error:', data.message);
}
} catch (error) {
console.error('Failed to load map data:', error);
} finally {
this.showLoading(false);
}
}
/**
* Update map markers with new data
*/
updateMarkers(data) {
// Clear existing markers
this.markers.clearLayers();
this.currentData = data;
// Add location markers
if (data.locations) {
data.locations.forEach(location => {
this.addLocationMarker(location);
});
}
// Add cluster markers (if not using Leaflet clustering)
if (data.clusters && !this.options.enableClustering) {
data.clusters.forEach(cluster => {
this.addClusterMarker(cluster);
});
}
}
/**
* Add a location marker to the map
*/
addLocationMarker(location) {
const icon = this.createLocationIcon(location);
const marker = L.marker([location.latitude, location.longitude], {
icon: icon,
locationData: location
});
// Create popup content
const popupContent = this.createPopupContent(location);
marker.bindPopup(popupContent, {
maxWidth: 300,
className: 'location-popup'
});
this.markers.addLayer(marker);
}
/**
* Add a cluster marker (for server-side clustering)
*/
addClusterMarker(cluster) {
const marker = L.marker([cluster.latitude, cluster.longitude], {
icon: L.divIcon({
className: 'cluster-marker server-cluster',
html: `<div class="cluster-marker-inner">${cluster.count}</div>`,
iconSize: [40, 40]
})
});
marker.bindPopup(`${cluster.count} locations in this area`);
this.markers.addLayer(marker);
}
/**
* Create location icon based on type
*/
createLocationIcon(location) {
const iconMap = {
'park': { emoji: '🎢', color: '#10B981' },
'ride': { emoji: '🎠', color: '#3B82F6' },
'company': { emoji: '🏢', color: '#8B5CF6' },
'generic': { emoji: '📍', color: '#6B7280' }
};
const iconData = iconMap[location.type] || iconMap.generic;
return L.divIcon({
className: 'location-marker',
html: `
<div class="location-marker-inner" style="background-color: ${iconData.color}">
<span class="location-marker-emoji">${iconData.emoji}</span>
</div>
`,
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15]
});
}
/**
* Create popup content for a location
*/
createPopupContent(location) {
return `
<div class="location-info-popup">
<h3 class="popup-title">${location.name}</h3>
${location.formatted_location ? `<p class="popup-location"><i class="fas fa-map-marker-alt"></i>${location.formatted_location}</p>` : ''}
${location.operator ? `<p class="popup-operator"><i class="fas fa-building"></i>${location.operator}</p>` : ''}
${location.ride_count ? `<p class="popup-rides"><i class="fas fa-rocket"></i>${location.ride_count} rides</p>` : ''}
${location.status ? `<p class="popup-status"><i class="fas fa-info-circle"></i>${location.status}</p>` : ''}
<div class="popup-actions">
<button onclick="window.thrillwikiMap.showLocationDetails('${location.type}', ${location.id})"
class="btn btn-primary btn-sm">
<i class="fas fa-eye"></i> View Details
</button>
</div>
</div>
`;
}
/**
* Show/hide loading indicator
*/
showLoading(show) {
const loadingElement = document.getElementById(`${this.containerId}-loading`) ||
document.getElementById('map-loading');
if (loadingElement) {
loadingElement.style.display = show ? 'flex' : 'none';
}
}
/**
* Handle map bounds change
*/
handleBoundsChange() {
clearTimeout(this.boundsUpdateTimeout);
this.boundsUpdateTimeout = setTimeout(() => {
const bounds = this.map.getBounds();
this.triggerEvent('boundsChange', bounds);
// Auto-reload data on significant bounds change
if (this.shouldReloadData()) {
this.loadLocations(bounds, this.getCurrentFilters());
}
}, 1000);
}
/**
* Handle location click
*/
handleLocationClick(location) {
this.triggerEvent('locationClick', location);
}
/**
* Show location details (integrate with HTMX)
*/
showLocationDetails(type, id) {
const url = `${this.options.apiEndpoints.details}${type}/${id}/`;
if (typeof htmx !== 'undefined') {
htmx.ajax('GET', url, {
target: '#location-modal',
swap: 'innerHTML'
}).then(() => {
const modal = document.getElementById('location-modal');
if (modal) {
modal.classList.remove('hidden');
}
});
} else {
// Fallback to regular navigation
window.location.href = url;
}
}
/**
* Get current filters from form
*/
getCurrentFilters() {
const form = document.getElementById('map-filters');
if (!form) return {};
const formData = new FormData(form);
const filters = {};
for (let [key, value] of formData.entries()) {
if (filters[key]) {
if (Array.isArray(filters[key])) {
filters[key].push(value);
} else {
filters[key] = [filters[key], value];
}
} else {
filters[key] = value;
}
}
return filters;
}
/**
* Update filters and reload data
*/
updateFilters(filters) {
const bounds = this.map.getBounds();
this.loadLocations(bounds, filters);
}
/**
* Enable user location features
*/
enableGeolocation() {
this.options.enableGeolocation = true;
this.map.locate({ setView: false, maxZoom: 16 });
}
/**
* Handle location found
*/
handleLocationFound(e) {
if (this.userLocation) {
this.map.removeLayer(this.userLocation);
}
this.userLocation = L.marker(e.latlng, {
icon: L.divIcon({
className: 'user-location-marker',
html: '<div class="user-location-inner"><i class="fas fa-crosshairs"></i></div>',
iconSize: [24, 24],
iconAnchor: [12, 12]
})
}).addTo(this.map);
this.userLocation.bindPopup('Your Location');
}
/**
* Handle location error
*/
handleLocationError(e) {
console.warn('Location access denied or unavailable:', e.message);
}
/**
* Determine if data should be reloaded based on map movement
*/
shouldReloadData() {
// Simple heuristic: reload if zoom changed or moved significantly
return true; // For now, always reload
}
/**
* Add event listener
*/
on(event, handler) {
if (!this.eventHandlers[event]) {
this.eventHandlers[event] = [];
}
this.eventHandlers[event].push(handler);
}
/**
* Remove event listener
*/
off(event, handler) {
if (this.eventHandlers[event]) {
const index = this.eventHandlers[event].indexOf(handler);
if (index > -1) {
this.eventHandlers[event].splice(index, 1);
}
}
}
/**
* Trigger event
*/
triggerEvent(event, data) {
if (this.eventHandlers[event]) {
this.eventHandlers[event].forEach(handler => {
try {
handler(data);
} catch (error) {
console.error(`Error in ${event} handler:`, error);
}
});
}
}
/**
* Export map view as image (requires html2canvas)
*/
async exportMap() {
if (typeof html2canvas === 'undefined') {
console.warn('html2canvas library not loaded, cannot export map');
return null;
}
try {
const canvas = await html2canvas(document.getElementById(this.containerId));
return canvas.toDataURL('image/png');
} catch (error) {
console.error('Failed to export map:', error);
return null;
}
}
/**
* Resize map (call when container size changes)
*/
invalidateSize() {
if (this.map) {
this.map.invalidateSize();
}
}
/**
* Get map bounds
*/
getBounds() {
return this.map ? this.map.getBounds() : null;
}
/**
* Set map view
*/
setView(latlng, zoom) {
if (this.map) {
this.map.setView(latlng, zoom);
}
}
/**
* Fit map to bounds
*/
fitBounds(bounds, options = {}) {
if (this.map) {
this.map.fitBounds(bounds, options);
}
}
/**
* Destroy map instance
*/
destroy() {
if (this.map) {
this.map.remove();
this.map = null;
}
// Clear timeouts
if (this.boundsUpdateTimeout) {
clearTimeout(this.boundsUpdateTimeout);
}
// Clear event handlers
this.eventHandlers = {};
}
}
// Auto-initialize maps with data attributes
document.addEventListener('DOMContentLoaded', function() {
// Find all elements with map-container class
const mapContainers = document.querySelectorAll('[data-map="auto"]');
mapContainers.forEach(container => {
const mapId = container.id;
const options = {};
// Parse data attributes for configuration
Object.keys(container.dataset).forEach(key => {
if (key.startsWith('map')) {
const optionKey = key.replace('map', '').toLowerCase();
let value = container.dataset[key];
// Try to parse as JSON for complex values
try {
value = JSON.parse(value);
} catch (e) {
// Keep as string if not valid JSON
}
options[optionKey] = value;
}
});
// Create map instance
window[`${mapId}Instance`] = new ThrillWikiMap(mapId, options);
});
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = ThrillWikiMap;
} else {
window.ThrillWikiMap = ThrillWikiMap;
}

View File

@@ -1,881 +0,0 @@
/**
* ThrillWiki Mobile Touch Support - Enhanced Mobile and Touch Experience
*
* This module provides mobile-optimized interactions, touch-friendly controls,
* responsive map sizing, and battery-conscious features for mobile devices
*/
class MobileTouchSupport {
constructor(options = {}) {
this.options = {
enableTouchOptimizations: true,
enableSwipeGestures: true,
enablePinchZoom: true,
enableResponsiveResize: true,
enableBatteryOptimization: true,
touchDebounceDelay: 150,
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
maxTouchPoints: 2,
orientationChangeDelay: 300,
...options
};
this.isMobile = this.detectMobileDevice();
this.isTouch = this.detectTouchSupport();
this.orientation = this.getOrientation();
this.mapInstances = new Set();
this.touchHandlers = new Map();
this.gestureState = {
isActive: false,
startDistance: 0,
startCenter: null,
lastTouchTime: 0
};
this.init();
}
/**
* Initialize mobile touch support
*/
init() {
if (!this.isTouch && !this.isMobile) {
console.log('Mobile touch support not needed for this device');
return;
}
this.setupTouchOptimizations();
this.setupSwipeGestures();
this.setupResponsiveHandling();
this.setupBatteryOptimization();
this.setupAccessibilityEnhancements();
this.bindEventHandlers();
console.log('Mobile touch support initialized');
}
/**
* Detect if device is mobile
*/
detectMobileDevice() {
const userAgent = navigator.userAgent.toLowerCase();
const mobileKeywords = ['android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'];
return mobileKeywords.some(keyword => userAgent.includes(keyword)) ||
window.innerWidth <= 768 ||
(typeof window.orientation !== 'undefined');
}
/**
* Detect touch support
*/
detectTouchSupport() {
return 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0;
}
/**
* Get current orientation
*/
getOrientation() {
if (screen.orientation) {
return screen.orientation.angle;
} else if (window.orientation !== undefined) {
return window.orientation;
}
return window.innerWidth > window.innerHeight ? 90 : 0;
}
/**
* Setup touch optimizations
*/
setupTouchOptimizations() {
if (!this.options.enableTouchOptimizations) return;
// Add touch-optimized styles
this.addTouchStyles();
// Enhance touch targets
this.enhanceTouchTargets();
// Optimize scroll behavior
this.optimizeScrollBehavior();
// Setup touch feedback
this.setupTouchFeedback();
}
/**
* Add touch-optimized CSS styles
*/
addTouchStyles() {
if (document.getElementById('mobile-touch-styles')) return;
const styles = `
<style id="mobile-touch-styles">
@media (max-width: 768px) {
/* Touch-friendly button sizes */
.btn, button, .filter-chip, .filter-pill {
min-height: 44px;
min-width: 44px;
padding: 12px 16px;
font-size: 16px;
}
/* Larger touch targets for map controls */
.leaflet-control-zoom a {
width: 44px !important;
height: 44px !important;
line-height: 44px !important;
font-size: 18px !important;
}
/* Mobile-optimized map containers */
.map-container {
height: 60vh !important;
min-height: 300px !important;
}
/* Touch-friendly popup styling */
.leaflet-popup-content-wrapper {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.leaflet-popup-content {
margin: 16px 20px;
font-size: 16px;
line-height: 1.5;
}
/* Improved form controls */
input, select, textarea {
font-size: 16px !important; /* Prevents zoom on iOS */
padding: 12px;
border-radius: 8px;
}
/* Touch-friendly filter panels */
.filter-panel {
padding: 16px;
border-radius: 12px;
}
/* Mobile navigation improvements */
.roadtrip-planner {
padding: 16px;
}
.parks-list .park-item {
padding: 16px;
margin-bottom: 12px;
border-radius: 12px;
touch-action: manipulation;
}
/* Swipe indicators */
.swipe-indicator {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 40px;
background: rgba(59, 130, 246, 0.5);
border-radius: 2px;
transition: opacity 0.3s ease;
}
.swipe-indicator.left {
left: 8px;
}
.swipe-indicator.right {
right: 8px;
}
}
@media (max-width: 480px) {
/* Extra small screens */
.map-container {
height: 50vh !important;
}
.filter-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
max-height: 50vh;
overflow-y: auto;
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
}
}
/* Touch feedback */
.touch-feedback {
position: relative;
overflow: hidden;
}
.touch-feedback::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.3s ease, height 0.3s ease;
pointer-events: none;
}
.touch-feedback.active::after {
width: 100px;
height: 100px;
}
/* Prevent text selection on mobile */
.no-select {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Optimize touch scrolling */
.touch-scroll {
-webkit-overflow-scrolling: touch;
overflow-scrolling: touch;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
/**
* Enhance touch targets for better accessibility
*/
enhanceTouchTargets() {
const smallTargets = document.querySelectorAll('button, .btn, a, input[type="checkbox"], input[type="radio"]');
smallTargets.forEach(target => {
const rect = target.getBoundingClientRect();
// If target is smaller than 44px (Apple's recommended minimum), enhance it
if (rect.width < 44 || rect.height < 44) {
target.classList.add('touch-enhanced');
target.style.minWidth = '44px';
target.style.minHeight = '44px';
target.style.display = 'inline-flex';
target.style.alignItems = 'center';
target.style.justifyContent = 'center';
}
});
}
/**
* Optimize scroll behavior for mobile
*/
optimizeScrollBehavior() {
// Add momentum scrolling to scrollable elements
const scrollableElements = document.querySelectorAll('.scrollable, .overflow-auto, .overflow-y-auto');
scrollableElements.forEach(element => {
element.classList.add('touch-scroll');
element.style.webkitOverflowScrolling = 'touch';
});
// Prevent body scroll when interacting with maps
document.addEventListener('touchstart', (e) => {
if (e.target.closest('.leaflet-container')) {
e.preventDefault();
}
}, { passive: false });
}
/**
* Setup touch feedback for interactive elements
*/
setupTouchFeedback() {
const interactiveElements = document.querySelectorAll('button, .btn, .filter-chip, .filter-pill, .park-item');
interactiveElements.forEach(element => {
element.classList.add('touch-feedback');
element.addEventListener('touchstart', (e) => {
element.classList.add('active');
setTimeout(() => {
element.classList.remove('active');
}, 300);
}, { passive: true });
});
}
/**
* Setup swipe gesture support
*/
setupSwipeGestures() {
if (!this.options.enableSwipeGestures) return;
let touchStartX = 0;
let touchStartY = 0;
let touchStartTime = 0;
document.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
}
}, { passive: true });
document.addEventListener('touchend', (e) => {
if (e.changedTouches.length === 1) {
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const touchEndTime = Date.now();
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
const deltaTime = touchEndTime - touchStartTime;
const velocity = Math.abs(deltaX) / deltaTime;
// Check if this is a swipe gesture
if (Math.abs(deltaX) > this.options.swipeThreshold &&
Math.abs(deltaY) < Math.abs(deltaX) &&
velocity > this.options.swipeVelocityThreshold) {
const direction = deltaX > 0 ? 'right' : 'left';
this.handleSwipeGesture(direction, e.target);
}
}
}, { passive: true });
}
/**
* Handle swipe gestures
*/
handleSwipeGesture(direction, target) {
// Handle swipe on filter panels
if (target.closest('.filter-panel')) {
if (direction === 'down' || direction === 'up') {
this.toggleFilterPanel();
}
}
// Handle swipe on road trip list
if (target.closest('.parks-list')) {
if (direction === 'left') {
this.showParkActions(target);
} else if (direction === 'right') {
this.hideParkActions(target);
}
}
// Emit custom swipe event
const swipeEvent = new CustomEvent('swipe', {
detail: { direction, target }
});
document.dispatchEvent(swipeEvent);
}
/**
* Setup responsive handling for orientation changes
*/
setupResponsiveHandling() {
if (!this.options.enableResponsiveResize) return;
// Handle orientation changes
window.addEventListener('orientationchange', () => {
setTimeout(() => {
this.handleOrientationChange();
}, this.options.orientationChangeDelay);
});
// Handle window resize
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
this.handleWindowResize();
}, 250);
});
// Handle viewport changes (for mobile browsers with dynamic toolbars)
this.setupViewportHandler();
}
/**
* Handle orientation change
*/
handleOrientationChange() {
const newOrientation = this.getOrientation();
if (newOrientation !== this.orientation) {
this.orientation = newOrientation;
// Resize all map instances
this.mapInstances.forEach(mapInstance => {
if (mapInstance.invalidateSize) {
mapInstance.invalidateSize();
}
});
// Emit orientation change event
const orientationEvent = new CustomEvent('orientationChanged', {
detail: { orientation: this.orientation }
});
document.dispatchEvent(orientationEvent);
}
}
/**
* Handle window resize
*/
handleWindowResize() {
// Update mobile detection
this.isMobile = this.detectMobileDevice();
// Resize map instances
this.mapInstances.forEach(mapInstance => {
if (mapInstance.invalidateSize) {
mapInstance.invalidateSize();
}
});
// Update touch targets
this.enhanceTouchTargets();
}
/**
* Setup viewport handler for dynamic mobile toolbars
*/
setupViewportHandler() {
// Use visual viewport API if available
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
this.handleViewportChange();
});
}
// Fallback for older browsers
let lastHeight = window.innerHeight;
const checkViewportChange = () => {
if (Math.abs(window.innerHeight - lastHeight) > 100) {
lastHeight = window.innerHeight;
this.handleViewportChange();
}
};
window.addEventListener('resize', checkViewportChange);
document.addEventListener('focusin', checkViewportChange);
document.addEventListener('focusout', checkViewportChange);
}
/**
* Handle viewport changes
*/
handleViewportChange() {
// Adjust map container heights
const mapContainers = document.querySelectorAll('.map-container');
mapContainers.forEach(container => {
const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
if (viewportHeight < 500) {
container.style.height = '40vh';
} else {
container.style.height = ''; // Reset to CSS default
}
});
}
/**
* Setup battery optimization
*/
setupBatteryOptimization() {
if (!this.options.enableBatteryOptimization) return;
// Reduce update frequency when battery is low
if ('getBattery' in navigator) {
navigator.getBattery().then(battery => {
const optimizeBattery = () => {
if (battery.level < 0.2) { // Battery below 20%
this.enableBatterySaveMode();
} else {
this.disableBatterySaveMode();
}
};
battery.addEventListener('levelchange', optimizeBattery);
optimizeBattery();
});
}
// Reduce activity when page is not visible
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.pauseNonEssentialFeatures();
} else {
this.resumeNonEssentialFeatures();
}
});
}
/**
* Enable battery save mode
*/
enableBatterySaveMode() {
console.log('Enabling battery save mode');
// Reduce map update frequency
this.mapInstances.forEach(mapInstance => {
if (mapInstance.options) {
mapInstance.options.updateInterval = 5000; // Increase to 5 seconds
}
});
// Disable animations
document.body.classList.add('battery-save-mode');
}
/**
* Disable battery save mode
*/
disableBatterySaveMode() {
console.log('Disabling battery save mode');
// Restore normal update frequency
this.mapInstances.forEach(mapInstance => {
if (mapInstance.options) {
mapInstance.options.updateInterval = 1000; // Restore to 1 second
}
});
// Re-enable animations
document.body.classList.remove('battery-save-mode');
}
/**
* Pause non-essential features
*/
pauseNonEssentialFeatures() {
// Pause location watching
if (window.userLocation && window.userLocation.stopWatching) {
window.userLocation.stopWatching();
}
// Reduce map updates
this.mapInstances.forEach(mapInstance => {
if (mapInstance.pauseUpdates) {
mapInstance.pauseUpdates();
}
});
}
/**
* Resume non-essential features
*/
resumeNonEssentialFeatures() {
// Resume location watching if it was active
if (window.userLocation && window.userLocation.options.watchPosition) {
window.userLocation.startWatching();
}
// Resume map updates
this.mapInstances.forEach(mapInstance => {
if (mapInstance.resumeUpdates) {
mapInstance.resumeUpdates();
}
});
}
/**
* Setup accessibility enhancements for mobile
*/
setupAccessibilityEnhancements() {
// Add focus indicators for touch navigation
const focusableElements = document.querySelectorAll('button, a, input, select, textarea, [tabindex]');
focusableElements.forEach(element => {
element.addEventListener('focus', () => {
element.classList.add('touch-focused');
});
element.addEventListener('blur', () => {
element.classList.remove('touch-focused');
});
});
// Enhance keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
document.body.classList.add('keyboard-navigation');
}
});
document.addEventListener('mousedown', () => {
document.body.classList.remove('keyboard-navigation');
});
}
/**
* Bind event handlers
*/
bindEventHandlers() {
// Handle double-tap to zoom
this.setupDoubleTapZoom();
// Handle long press
this.setupLongPress();
// Handle pinch gestures
if (this.options.enablePinchZoom) {
this.setupPinchZoom();
}
}
/**
* Setup double-tap to zoom
*/
setupDoubleTapZoom() {
let lastTapTime = 0;
document.addEventListener('touchend', (e) => {
const currentTime = Date.now();
if (currentTime - lastTapTime < 300) {
// Double tap detected
const target = e.target;
if (target.closest('.leaflet-container')) {
this.handleDoubleTapZoom(e);
}
}
lastTapTime = currentTime;
}, { passive: true });
}
/**
* Handle double-tap zoom
*/
handleDoubleTapZoom(e) {
const mapContainer = e.target.closest('.leaflet-container');
if (!mapContainer) return;
// Find associated map instance
this.mapInstances.forEach(mapInstance => {
if (mapInstance.getContainer() === mapContainer) {
const currentZoom = mapInstance.getZoom();
const newZoom = currentZoom < mapInstance.getMaxZoom() ? currentZoom + 2 : mapInstance.getMinZoom();
mapInstance.setZoom(newZoom, {
animate: true,
duration: 0.3
});
}
});
}
/**
* Setup long press detection
*/
setupLongPress() {
let pressTimer;
document.addEventListener('touchstart', (e) => {
pressTimer = setTimeout(() => {
this.handleLongPress(e);
}, 750); // 750ms for long press
}, { passive: true });
document.addEventListener('touchend', () => {
clearTimeout(pressTimer);
}, { passive: true });
document.addEventListener('touchmove', () => {
clearTimeout(pressTimer);
}, { passive: true });
}
/**
* Handle long press
*/
handleLongPress(e) {
const target = e.target;
// Emit long press event
const longPressEvent = new CustomEvent('longPress', {
detail: { target, touches: e.touches }
});
target.dispatchEvent(longPressEvent);
// Provide haptic feedback if available
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
/**
* Setup pinch zoom for maps
*/
setupPinchZoom() {
document.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
this.gestureState.isActive = true;
this.gestureState.startDistance = this.getDistance(e.touches[0], e.touches[1]);
this.gestureState.startCenter = this.getCenter(e.touches[0], e.touches[1]);
}
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (this.gestureState.isActive && e.touches.length === 2) {
this.handlePinchZoom(e);
}
}, { passive: false });
document.addEventListener('touchend', () => {
this.gestureState.isActive = false;
}, { passive: true });
}
/**
* Handle pinch zoom gesture
*/
handlePinchZoom(e) {
if (!e.target.closest('.leaflet-container')) return;
const currentDistance = this.getDistance(e.touches[0], e.touches[1]);
const scale = currentDistance / this.gestureState.startDistance;
// Emit pinch event
const pinchEvent = new CustomEvent('pinch', {
detail: { scale, center: this.gestureState.startCenter }
});
e.target.dispatchEvent(pinchEvent);
}
/**
* Get distance between two touch points
*/
getDistance(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Get center point between two touches
*/
getCenter(touch1, touch2) {
return {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2
};
}
/**
* Register map instance for mobile optimizations
*/
registerMapInstance(mapInstance) {
this.mapInstances.add(mapInstance);
// Apply mobile-specific map options
if (this.isMobile && mapInstance.options) {
mapInstance.options.zoomControl = false; // Use custom larger controls
mapInstance.options.attributionControl = false; // Save space
}
}
/**
* Unregister map instance
*/
unregisterMapInstance(mapInstance) {
this.mapInstances.delete(mapInstance);
}
/**
* Toggle filter panel for mobile
*/
toggleFilterPanel() {
const filterPanel = document.querySelector('.filter-panel');
if (filterPanel) {
filterPanel.classList.toggle('mobile-expanded');
}
}
/**
* Show park actions on swipe
*/
showParkActions(target) {
const parkItem = target.closest('.park-item');
if (parkItem) {
parkItem.classList.add('actions-visible');
}
}
/**
* Hide park actions
*/
hideParkActions(target) {
const parkItem = target.closest('.park-item');
if (parkItem) {
parkItem.classList.remove('actions-visible');
}
}
/**
* Check if device is mobile
*/
isMobileDevice() {
return this.isMobile;
}
/**
* Check if device supports touch
*/
isTouchDevice() {
return this.isTouch;
}
/**
* Get device info
*/
getDeviceInfo() {
return {
isMobile: this.isMobile,
isTouch: this.isTouch,
orientation: this.orientation,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
pixelRatio: window.devicePixelRatio || 1
};
}
}
// Auto-initialize mobile touch support
document.addEventListener('DOMContentLoaded', function() {
window.mobileTouchSupport = new MobileTouchSupport();
// Register existing map instances
if (window.thrillwikiMap) {
window.mobileTouchSupport.registerMapInstance(window.thrillwikiMap);
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = MobileTouchSupport;
} else {
window.MobileTouchSupport = MobileTouchSupport;
}

View File

@@ -1,29 +0,0 @@
// Only declare parkMap if it doesn't exist
window.parkMap = window.parkMap || null;
function initParkMap(latitude, longitude, name) {
const mapContainer = document.getElementById('park-map');
// Only initialize if container exists and map hasn't been initialized
if (mapContainer && !window.parkMap) {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
window.parkMap = L.map('park-map').setView([latitude, longitude], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(window.parkMap);
L.marker([latitude, longitude])
.addTo(window.parkMap)
.bindPopup(name);
// Update map size when window is resized
window.addEventListener('resize', function() {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
window.parkMap.invalidateSize();
});
}
}

View File

@@ -1,91 +0,0 @@
document.addEventListener('alpine:init', () => {
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
photos,
fullscreenPhoto: null,
uploading: false,
uploadProgress: 0,
error: null,
showSuccess: false,
showFullscreen(photo) {
this.fullscreenPhoto = photo;
},
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) {
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
this.showSuccess = false;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
break;
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
if (!this.error) {
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
},
async sharePhoto(photo) {
if (navigator.share) {
try {
await navigator.share({
title: photo.caption || 'Shared photo',
url: photo.url
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
} else {
// Fallback: copy URL to clipboard
navigator.clipboard.writeText(photo.url)
.then(() => alert('Photo URL copied to clipboard!'))
.catch(err => console.error('Error copying to clipboard:', err));
}
}
}));
});

View File

@@ -1,774 +0,0 @@
/**
* ThrillWiki Road Trip Planner - Multi-park Route Planning
*
* This module provides road trip planning functionality with multi-park selection,
* route visualization, distance calculations, and export capabilities
*/
class RoadTripPlanner {
constructor(containerId, options = {}) {
this.containerId = containerId;
this.options = {
mapInstance: null,
maxParks: 20,
enableOptimization: true,
enableExport: true,
apiEndpoints: {
parks: '/api/parks/',
route: '/api/roadtrip/route/',
optimize: '/api/roadtrip/optimize/',
export: '/api/roadtrip/export/'
},
routeOptions: {
color: '#3B82F6',
weight: 4,
opacity: 0.8
},
...options
};
this.container = null;
this.mapInstance = null;
this.selectedParks = [];
this.routeLayer = null;
this.parkMarkers = new Map();
this.routePolyline = null;
this.routeData = null;
this.init();
}
/**
* Initialize the road trip planner
*/
init() {
this.container = document.getElementById(this.containerId);
if (!this.container) {
console.error(`Road trip container with ID '${this.containerId}' not found`);
return;
}
this.setupUI();
this.bindEvents();
// Connect to map instance if provided
if (this.options.mapInstance) {
this.connectToMap(this.options.mapInstance);
}
this.loadInitialData();
}
/**
* Setup the UI components
*/
setupUI() {
const html = `
<div class="roadtrip-planner">
<div class="roadtrip-header">
<h3 class="roadtrip-title">
<i class="fas fa-route"></i>
Road Trip Planner
</h3>
<div class="roadtrip-controls">
<button id="optimize-route" class="btn btn-secondary btn-sm" disabled>
<i class="fas fa-magic"></i> Optimize Route
</button>
<button id="clear-route" class="btn btn-outline btn-sm" disabled>
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
<div class="roadtrip-content">
<div class="park-selection">
<div class="search-parks">
<input type="text" id="park-search"
placeholder="Search parks to add..."
class="form-input">
<div id="park-search-results" class="search-results"></div>
</div>
</div>
<div class="selected-parks">
<h4 class="section-title">Your Route (<span id="park-count">0</span>/${this.options.maxParks})</h4>
<div id="parks-list" class="parks-list sortable">
<div class="empty-state">
<i class="fas fa-map-marked-alt"></i>
<p>Search and select parks to build your road trip route</p>
</div>
</div>
</div>
<div class="route-summary" id="route-summary" style="display: none;">
<h4 class="section-title">Trip Summary</h4>
<div class="summary-stats">
<div class="stat">
<span class="stat-label">Total Distance:</span>
<span id="total-distance" class="stat-value">-</span>
</div>
<div class="stat">
<span class="stat-label">Driving Time:</span>
<span id="total-time" class="stat-value">-</span>
</div>
<div class="stat">
<span class="stat-label">Parks:</span>
<span id="total-parks" class="stat-value">0</span>
</div>
</div>
<div class="export-options">
<button id="export-gpx" class="btn btn-outline btn-sm">
<i class="fas fa-download"></i> Export GPX
</button>
<button id="export-kml" class="btn btn-outline btn-sm">
<i class="fas fa-download"></i> Export KML
</button>
<button id="share-route" class="btn btn-primary btn-sm">
<i class="fas fa-share"></i> Share Route
</button>
</div>
</div>
</div>
</div>
`;
this.container.innerHTML = html;
}
/**
* Bind event handlers
*/
bindEvents() {
// Park search
const searchInput = document.getElementById('park-search');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.searchParks(e.target.value);
}, 300);
});
}
// Route controls
const optimizeBtn = document.getElementById('optimize-route');
if (optimizeBtn) {
optimizeBtn.addEventListener('click', () => this.optimizeRoute());
}
const clearBtn = document.getElementById('clear-route');
if (clearBtn) {
clearBtn.addEventListener('click', () => this.clearRoute());
}
// Export buttons
const exportGpxBtn = document.getElementById('export-gpx');
if (exportGpxBtn) {
exportGpxBtn.addEventListener('click', () => this.exportRoute('gpx'));
}
const exportKmlBtn = document.getElementById('export-kml');
if (exportKmlBtn) {
exportKmlBtn.addEventListener('click', () => this.exportRoute('kml'));
}
const shareBtn = document.getElementById('share-route');
if (shareBtn) {
shareBtn.addEventListener('click', () => this.shareRoute());
}
// Make parks list sortable
this.initializeSortable();
}
/**
* Initialize drag-and-drop sorting for parks list
*/
initializeSortable() {
const parksList = document.getElementById('parks-list');
if (!parksList) return;
// Simple drag and drop implementation
let draggedElement = null;
parksList.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('park-item')) {
draggedElement = e.target;
e.target.style.opacity = '0.5';
}
});
parksList.addEventListener('dragend', (e) => {
if (e.target.classList.contains('park-item')) {
e.target.style.opacity = '1';
draggedElement = null;
}
});
parksList.addEventListener('dragover', (e) => {
e.preventDefault();
});
parksList.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedElement && e.target.classList.contains('park-item')) {
const afterElement = this.getDragAfterElement(parksList, e.clientY);
if (afterElement == null) {
parksList.appendChild(draggedElement);
} else {
parksList.insertBefore(draggedElement, afterElement);
}
this.reorderParks();
}
});
}
/**
* Get the element to insert after during drag and drop
*/
getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.park-item:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
/**
* Search for parks
*/
async searchParks(query) {
if (!query.trim()) {
document.getElementById('park-search-results').innerHTML = '';
return;
}
try {
const response = await fetch(`${this.options.apiEndpoints.parks}?q=${encodeURIComponent(query)}&limit=10`);
const data = await response.json();
if (data.status === 'success') {
this.displaySearchResults(data.data);
}
} catch (error) {
console.error('Failed to search parks:', error);
}
}
/**
* Display park search results
*/
displaySearchResults(parks) {
const resultsContainer = document.getElementById('park-search-results');
if (parks.length === 0) {
resultsContainer.innerHTML = '<div class="no-results">No parks found</div>';
return;
}
const html = parks
.filter(park => !this.isParkSelected(park.id))
.map(park => `
<div class="search-result-item" data-park-id="${park.id}">
<div class="park-info">
<div class="park-name">${park.name}</div>
<div class="park-location">${park.formatted_location || ''}</div>
</div>
<button class="add-park-btn" onclick="roadTripPlanner.addPark(${park.id})">
<i class="fas fa-plus"></i>
</button>
</div>
`).join('');
resultsContainer.innerHTML = html;
}
/**
* Check if a park is already selected
*/
isParkSelected(parkId) {
return this.selectedParks.some(park => park.id === parkId);
}
/**
* Add a park to the route
*/
async addPark(parkId) {
if (this.selectedParks.length >= this.options.maxParks) {
this.showMessage(`Maximum ${this.options.maxParks} parks allowed`, 'warning');
return;
}
try {
const response = await fetch(`${this.options.apiEndpoints.parks}${parkId}/`);
const data = await response.json();
if (data.status === 'success') {
const park = data.data;
this.selectedParks.push(park);
this.updateParksDisplay();
this.addParkMarker(park);
this.updateRoute();
// Clear search
document.getElementById('park-search').value = '';
document.getElementById('park-search-results').innerHTML = '';
}
} catch (error) {
console.error('Failed to add park:', error);
}
}
/**
* Remove a park from the route
*/
removePark(parkId) {
const index = this.selectedParks.findIndex(park => park.id === parkId);
if (index > -1) {
this.selectedParks.splice(index, 1);
this.updateParksDisplay();
this.removeParkMarker(parkId);
this.updateRoute();
}
}
/**
* Update the parks display
*/
updateParksDisplay() {
const parksList = document.getElementById('parks-list');
const parkCount = document.getElementById('park-count');
parkCount.textContent = this.selectedParks.length;
if (this.selectedParks.length === 0) {
parksList.innerHTML = `
<div class="empty-state">
<i class="fas fa-map-marked-alt"></i>
<p>Search and select parks to build your road trip route</p>
</div>
`;
this.updateControls();
return;
}
const html = this.selectedParks.map((park, index) => `
<div class="park-item" draggable="true" data-park-id="${park.id}">
<div class="park-number">${index + 1}</div>
<div class="park-details">
<div class="park-name">${park.name}</div>
<div class="park-location">${park.formatted_location || ''}</div>
${park.distance_from_previous ? `<div class="park-distance">${park.distance_from_previous}</div>` : ''}
</div>
<div class="park-actions">
<button class="btn-icon" onclick="roadTripPlanner.removePark(${park.id})" title="Remove park">
<i class="fas fa-times"></i>
</button>
</div>
</div>
`).join('');
parksList.innerHTML = html;
this.updateControls();
}
/**
* Update control buttons state
*/
updateControls() {
const optimizeBtn = document.getElementById('optimize-route');
const clearBtn = document.getElementById('clear-route');
const hasParks = this.selectedParks.length > 0;
const canOptimize = this.selectedParks.length > 2;
if (optimizeBtn) optimizeBtn.disabled = !canOptimize;
if (clearBtn) clearBtn.disabled = !hasParks;
}
/**
* Reorder parks after drag and drop
*/
reorderParks() {
const parkItems = document.querySelectorAll('.park-item');
const newOrder = [];
parkItems.forEach(item => {
const parkId = parseInt(item.dataset.parkId);
const park = this.selectedParks.find(p => p.id === parkId);
if (park) {
newOrder.push(park);
}
});
this.selectedParks = newOrder;
this.updateRoute();
}
/**
* Update the route visualization
*/
async updateRoute() {
if (this.selectedParks.length < 2) {
this.clearRouteVisualization();
this.updateRouteSummary(null);
return;
}
try {
const parkIds = this.selectedParks.map(park => park.id);
const response = await fetch(`${this.options.apiEndpoints.route}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({ parks: parkIds })
});
const data = await response.json();
if (data.status === 'success') {
this.routeData = data.data;
this.visualizeRoute(data.data);
this.updateRouteSummary(data.data);
}
} catch (error) {
console.error('Failed to calculate route:', error);
}
}
/**
* Visualize the route on the map
*/
visualizeRoute(routeData) {
if (!this.mapInstance) return;
// Clear existing route
this.clearRouteVisualization();
if (routeData.coordinates) {
// Create polyline from coordinates
this.routePolyline = L.polyline(routeData.coordinates, this.options.routeOptions);
this.routePolyline.addTo(this.mapInstance);
// Fit map to route bounds
if (routeData.coordinates.length > 0) {
this.mapInstance.fitBounds(this.routePolyline.getBounds(), { padding: [20, 20] });
}
}
}
/**
* Clear route visualization
*/
clearRouteVisualization() {
if (this.routePolyline && this.mapInstance) {
this.mapInstance.removeLayer(this.routePolyline);
this.routePolyline = null;
}
}
/**
* Update route summary display
*/
updateRouteSummary(routeData) {
const summarySection = document.getElementById('route-summary');
if (!routeData || this.selectedParks.length < 2) {
summarySection.style.display = 'none';
return;
}
summarySection.style.display = 'block';
document.getElementById('total-distance').textContent = routeData.total_distance || '-';
document.getElementById('total-time').textContent = routeData.total_time || '-';
document.getElementById('total-parks').textContent = this.selectedParks.length;
}
/**
* Optimize the route order
*/
async optimizeRoute() {
if (this.selectedParks.length < 3) return;
try {
const parkIds = this.selectedParks.map(park => park.id);
const response = await fetch(`${this.options.apiEndpoints.optimize}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({ parks: parkIds })
});
const data = await response.json();
if (data.status === 'success') {
// Reorder parks based on optimization
const optimizedOrder = data.data.optimized_order;
this.selectedParks = optimizedOrder.map(id =>
this.selectedParks.find(park => park.id === id)
).filter(Boolean);
this.updateParksDisplay();
this.updateRoute();
this.showMessage('Route optimized for shortest distance', 'success');
}
} catch (error) {
console.error('Failed to optimize route:', error);
this.showMessage('Failed to optimize route', 'error');
}
}
/**
* Clear the entire route
*/
clearRoute() {
this.selectedParks = [];
this.clearAllParkMarkers();
this.clearRouteVisualization();
this.updateParksDisplay();
this.updateRouteSummary(null);
}
/**
* Export route in specified format
*/
async exportRoute(format) {
if (!this.routeData) {
this.showMessage('No route to export', 'warning');
return;
}
try {
const response = await fetch(`${this.options.apiEndpoints.export}${format}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
parks: this.selectedParks.map(p => p.id),
route_data: this.routeData
})
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `thrillwiki-roadtrip.${format}`;
a.click();
window.URL.revokeObjectURL(url);
}
} catch (error) {
console.error('Failed to export route:', error);
this.showMessage('Failed to export route', 'error');
}
}
/**
* Share the route
*/
shareRoute() {
if (this.selectedParks.length === 0) {
this.showMessage('No route to share', 'warning');
return;
}
const parkIds = this.selectedParks.map(p => p.id).join(',');
const url = `${window.location.origin}/roadtrip/?parks=${parkIds}`;
if (navigator.share) {
navigator.share({
title: 'ThrillWiki Road Trip',
text: `Check out this ${this.selectedParks.length}-park road trip!`,
url: url
});
} else {
// Fallback to clipboard
navigator.clipboard.writeText(url).then(() => {
this.showMessage('Route URL copied to clipboard', 'success');
}).catch(() => {
// Manual selection fallback
const textarea = document.createElement('textarea');
textarea.value = url;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
this.showMessage('Route URL copied to clipboard', 'success');
});
}
}
/**
* Add park marker to map
*/
addParkMarker(park) {
if (!this.mapInstance) return;
const marker = L.marker([park.latitude, park.longitude], {
icon: this.createParkIcon(park)
});
marker.bindPopup(`
<div class="park-popup">
<h4>${park.name}</h4>
<p>${park.formatted_location || ''}</p>
<button onclick="roadTripPlanner.removePark(${park.id})" class="btn btn-sm btn-outline">
Remove from Route
</button>
</div>
`);
marker.addTo(this.mapInstance);
this.parkMarkers.set(park.id, marker);
}
/**
* Remove park marker from map
*/
removeParkMarker(parkId) {
if (this.parkMarkers.has(parkId) && this.mapInstance) {
this.mapInstance.removeLayer(this.parkMarkers.get(parkId));
this.parkMarkers.delete(parkId);
}
}
/**
* Clear all park markers
*/
clearAllParkMarkers() {
this.parkMarkers.forEach(marker => {
if (this.mapInstance) {
this.mapInstance.removeLayer(marker);
}
});
this.parkMarkers.clear();
}
/**
* Create custom icon for park marker
*/
createParkIcon(park) {
const index = this.selectedParks.findIndex(p => p.id === park.id) + 1;
return L.divIcon({
className: 'roadtrip-park-marker',
html: `<div class="park-marker-inner">${index}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
}
/**
* Connect to a map instance
*/
connectToMap(mapInstance) {
this.mapInstance = mapInstance;
this.options.mapInstance = mapInstance;
}
/**
* Load initial data (from URL parameters)
*/
loadInitialData() {
const urlParams = new URLSearchParams(window.location.search);
const parkIds = urlParams.get('parks');
if (parkIds) {
const ids = parkIds.split(',').map(id => parseInt(id)).filter(id => !isNaN(id));
this.loadParksById(ids);
}
}
/**
* Load parks by IDs
*/
async loadParksById(parkIds) {
try {
const promises = parkIds.map(id =>
fetch(`${this.options.apiEndpoints.parks}${id}/`)
.then(res => res.json())
.then(data => data.status === 'success' ? data.data : null)
);
const parks = (await Promise.all(promises)).filter(Boolean);
this.selectedParks = parks;
this.updateParksDisplay();
// Add markers and update route
parks.forEach(park => this.addParkMarker(park));
this.updateRoute();
} catch (error) {
console.error('Failed to load parks:', error);
}
}
/**
* Get CSRF token for POST requests
*/
getCsrfToken() {
const token = document.querySelector('[name=csrfmiddlewaretoken]');
return token ? token.value : '';
}
/**
* Show message to user
*/
showMessage(message, type = 'info') {
// Create or update message element
let messageEl = this.container.querySelector('.roadtrip-message');
if (!messageEl) {
messageEl = document.createElement('div');
messageEl.className = 'roadtrip-message';
this.container.insertBefore(messageEl, this.container.firstChild);
}
messageEl.textContent = message;
messageEl.className = `roadtrip-message roadtrip-message-${type}`;
// Auto-hide after delay
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.remove();
}
}, 5000);
}
}
// Auto-initialize road trip planner
document.addEventListener('DOMContentLoaded', function() {
const roadtripContainer = document.getElementById('roadtrip-planner');
if (roadtripContainer) {
window.roadTripPlanner = new RoadTripPlanner('roadtrip-planner', {
mapInstance: window.thrillwikiMap || null
});
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = RoadTripPlanner;
} else {
window.RoadTripPlanner = RoadTripPlanner;
}

View File

@@ -1,42 +0,0 @@
function parkSearch() {
return {
query: '',
results: [],
loading: false,
selectedId: null,
async search() {
if (!this.query.trim()) {
this.results = [];
return;
}
this.loading = true;
try {
const response = await fetch(`/parks/suggest_parks/?search=${encodeURIComponent(this.query)}`);
const data = await response.json();
this.results = data.results;
} catch (error) {
console.error('Search failed:', error);
this.results = [];
} finally {
this.loading = false;
}
},
clear() {
this.query = '';
this.results = [];
this.selectedId = null;
},
selectPark(park) {
this.query = park.name;
this.selectedId = park.id;
this.results = [];
// Trigger filter update
document.getElementById('park-filters').dispatchEvent(new Event('change'));
}
};
}

View File

@@ -1,16 +0,0 @@
/**
* Theme management script
* Prevents flash of wrong theme by setting theme class immediately
*/
(function() {
let theme = localStorage.getItem("theme");
if (!theme) {
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
localStorage.setItem("theme", theme);
}
if (theme === "dark") {
document.documentElement.classList.add("dark");
}
})();

View File

@@ -1,799 +0,0 @@
/**
* ThrillWiki Enhanced JavaScript
* Advanced interactions, animations, and UI enhancements
* Last Updated: 2025-01-15
*/
// Global ThrillWiki namespace
window.ThrillWiki = window.ThrillWiki || {};
(function(TW) {
'use strict';
// Configuration
TW.config = {
animationDuration: 300,
scrollOffset: 80,
debounceDelay: 300,
apiEndpoints: {
search: '/api/search/',
favorites: '/api/favorites/',
notifications: '/api/notifications/'
}
};
// Utility functions
TW.utils = {
// Debounce function for performance
debounce: function(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// Throttle function for scroll events
throttle: function(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
},
// Smooth scroll to element
scrollTo: function(element, offset = TW.config.scrollOffset) {
const targetPosition = element.offsetTop - offset;
window.scrollTo({
top: targetPosition,
behavior: 'smooth'
});
},
// Check if element is in viewport
isInViewport: function(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
},
// Format numbers with commas
formatNumber: function(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
},
// Generate unique ID
generateId: function() {
return 'tw-' + Math.random().toString(36).substr(2, 9);
}
};
// Animation system
TW.animations = {
// Fade in animation
fadeIn: function(element, duration = TW.config.animationDuration) {
element.style.opacity = '0';
element.style.display = 'block';
const fadeEffect = setInterval(() => {
if (!element.style.opacity) {
element.style.opacity = 0;
}
if (element.style.opacity < 1) {
element.style.opacity = parseFloat(element.style.opacity) + 0.1;
} else {
clearInterval(fadeEffect);
}
}, duration / 10);
},
// Slide in from bottom
slideInUp: function(element, duration = TW.config.animationDuration) {
element.style.transform = 'translateY(30px)';
element.style.opacity = '0';
element.style.transition = `all ${duration}ms cubic-bezier(0.16, 1, 0.3, 1)`;
setTimeout(() => {
element.style.transform = 'translateY(0)';
element.style.opacity = '1';
}, 10);
},
// Pulse effect
pulse: function(element, intensity = 1.05) {
element.style.transition = 'transform 0.15s ease-out';
element.style.transform = `scale(${intensity})`;
setTimeout(() => {
element.style.transform = 'scale(1)';
}, 150);
},
// Shake effect for errors
shake: function(element) {
element.style.animation = 'shake 0.5s ease-in-out';
setTimeout(() => {
element.style.animation = '';
}, 500);
}
};
// Enhanced search functionality
TW.search = {
init: function() {
this.setupQuickSearch();
this.setupAdvancedSearch();
this.setupSearchSuggestions();
},
setupQuickSearch: function() {
const quickSearchInputs = document.querySelectorAll('[data-quick-search]');
quickSearchInputs.forEach(input => {
const debouncedSearch = TW.utils.debounce(this.performQuickSearch.bind(this), TW.config.debounceDelay);
input.addEventListener('input', (e) => {
const query = e.target.value.trim();
if (query.length >= 2) {
debouncedSearch(query, e.target);
} else {
this.clearSearchResults(e.target);
}
});
// Handle keyboard navigation
input.addEventListener('keydown', this.handleSearchKeyboard.bind(this));
});
},
performQuickSearch: function(query, inputElement) {
const resultsContainer = document.getElementById(inputElement.dataset.quickSearch);
if (!resultsContainer) return;
// Show loading state
resultsContainer.innerHTML = this.getLoadingHTML();
resultsContainer.classList.remove('hidden');
// Perform search
fetch(`${TW.config.apiEndpoints.search}?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
this.displaySearchResults(data, resultsContainer);
})
.catch(error => {
console.error('Search error:', error);
resultsContainer.innerHTML = this.getErrorHTML();
});
},
displaySearchResults: function(data, container) {
if (!data.results || data.results.length === 0) {
container.innerHTML = this.getNoResultsHTML();
return;
}
let html = '<div class="search-results-dropdown">';
// Group results by type
const groupedResults = this.groupResultsByType(data.results);
Object.keys(groupedResults).forEach(type => {
if (groupedResults[type].length > 0) {
html += `<div class="search-group">
<h4 class="search-group-title">${this.getTypeTitle(type)}</h4>
<div class="search-group-items">`;
groupedResults[type].forEach(result => {
html += this.getResultItemHTML(result);
});
html += '</div></div>';
}
});
html += '</div>';
container.innerHTML = html;
// Add click handlers
this.attachResultClickHandlers(container);
},
getResultItemHTML: function(result) {
return `
<div class="search-result-item" data-url="${result.url}" data-type="${result.type}">
<div class="search-result-icon">
<i class="fas fa-${this.getTypeIcon(result.type)}"></i>
</div>
<div class="search-result-content">
<div class="search-result-title">${result.name}</div>
<div class="search-result-subtitle">${result.subtitle || ''}</div>
</div>
${result.image ? `<div class="search-result-image">
<img src="${result.image}" alt="${result.name}" loading="lazy">
</div>` : ''}
</div>
`;
},
groupResultsByType: function(results) {
return results.reduce((groups, result) => {
const type = result.type || 'other';
if (!groups[type]) groups[type] = [];
groups[type].push(result);
return groups;
}, {});
},
getTypeTitle: function(type) {
const titles = {
'park': 'Theme Parks',
'ride': 'Rides & Attractions',
'location': 'Locations',
'other': 'Other Results'
};
return titles[type] || 'Results';
},
getTypeIcon: function(type) {
const icons = {
'park': 'map-marked-alt',
'ride': 'rocket',
'location': 'map-marker-alt',
'other': 'search'
};
return icons[type] || 'search';
},
getLoadingHTML: function() {
return `
<div class="search-loading">
<div class="loading-spinner opacity-100">
<i class="fas fa-spinner text-thrill-primary"></i>
</div>
<span>Searching...</span>
</div>
`;
},
getNoResultsHTML: function() {
return `
<div class="search-no-results">
<i class="fas fa-search text-neutral-400"></i>
<span>No results found</span>
</div>
`;
},
getErrorHTML: function() {
return `
<div class="search-error">
<i class="fas fa-exclamation-triangle text-red-500"></i>
<span>Search error. Please try again.</span>
</div>
`;
},
attachResultClickHandlers: function(container) {
const resultItems = container.querySelectorAll('.search-result-item');
resultItems.forEach(item => {
item.addEventListener('click', (e) => {
const url = item.dataset.url;
if (url) {
// Use HTMX if available, otherwise navigate normally
if (window.htmx) {
htmx.ajax('GET', url, {
target: '#main-content',
swap: 'innerHTML transition:true'
});
} else {
window.location.href = url;
}
// Clear search
this.clearSearchResults(container.previousElementSibling);
}
});
});
},
clearSearchResults: function(inputElement) {
const resultsContainer = document.getElementById(inputElement.dataset.quickSearch);
if (resultsContainer) {
resultsContainer.classList.add('hidden');
resultsContainer.innerHTML = '';
}
},
handleSearchKeyboard: function(e) {
// Handle escape key to close results
if (e.key === 'Escape') {
this.clearSearchResults(e.target);
}
}
};
// Enhanced card interactions
TW.cards = {
init: function() {
this.setupCardHovers();
this.setupFavoriteButtons();
this.setupCardAnimations();
},
setupCardHovers: function() {
const cards = document.querySelectorAll('.card-park, .card-ride, .card-feature');
cards.forEach(card => {
card.addEventListener('mouseenter', () => {
this.onCardHover(card);
});
card.addEventListener('mouseleave', () => {
this.onCardLeave(card);
});
});
},
onCardHover: function(card) {
// Add subtle glow effect
card.style.boxShadow = '0 20px 40px rgba(99, 102, 241, 0.15)';
// Animate card image if present
const image = card.querySelector('.card-park-image, .card-ride-image');
if (image) {
image.style.transform = 'scale(1.05)';
}
// Show hidden elements
const hiddenElements = card.querySelectorAll('.opacity-0');
hiddenElements.forEach(el => {
el.style.opacity = '1';
el.style.transform = 'translateY(0)';
});
},
onCardLeave: function(card) {
// Reset styles
card.style.boxShadow = '';
const image = card.querySelector('.card-park-image, .card-ride-image');
if (image) {
image.style.transform = '';
}
},
setupFavoriteButtons: function() {
document.addEventListener('click', (e) => {
if (e.target.closest('[data-favorite-toggle]')) {
e.preventDefault();
e.stopPropagation();
const button = e.target.closest('[data-favorite-toggle]');
this.toggleFavorite(button);
}
});
},
toggleFavorite: function(button) {
const itemId = button.dataset.favoriteToggle;
const itemType = button.dataset.favoriteType || 'park';
// Optimistic UI update
const icon = button.querySelector('i');
const isFavorited = icon.classList.contains('fas');
if (isFavorited) {
icon.classList.remove('fas', 'text-red-500');
icon.classList.add('far', 'text-neutral-600', 'dark:text-neutral-400');
} else {
icon.classList.remove('far', 'text-neutral-600', 'dark:text-neutral-400');
icon.classList.add('fas', 'text-red-500');
}
// Animate button
TW.animations.pulse(button, 1.2);
// Send request to server
fetch(`${TW.config.apiEndpoints.favorites}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCSRFToken()
},
body: JSON.stringify({
item_id: itemId,
item_type: itemType,
action: isFavorited ? 'remove' : 'add'
})
})
.then(response => response.json())
.then(data => {
if (!data.success) {
// Revert optimistic update
if (isFavorited) {
icon.classList.remove('far', 'text-neutral-600', 'dark:text-neutral-400');
icon.classList.add('fas', 'text-red-500');
} else {
icon.classList.remove('fas', 'text-red-500');
icon.classList.add('far', 'text-neutral-600', 'dark:text-neutral-400');
}
TW.notifications.show('Error updating favorite', 'error');
}
})
.catch(error => {
console.error('Favorite toggle error:', error);
TW.notifications.show('Error updating favorite', 'error');
});
},
getCSRFToken: function() {
const token = document.querySelector('[name=csrfmiddlewaretoken]');
return token ? token.value : '';
}
};
// Enhanced notifications system
TW.notifications = {
container: null,
init: function() {
this.createContainer();
this.setupAutoHide();
},
createContainer: function() {
if (!this.container) {
this.container = document.createElement('div');
this.container.id = 'tw-notifications';
this.container.className = 'fixed top-4 right-4 z-50 space-y-4';
document.body.appendChild(this.container);
}
},
show: function(message, type = 'info', duration = 5000) {
const notification = this.createNotification(message, type);
this.container.appendChild(notification);
// Animate in
setTimeout(() => {
notification.classList.add('show');
}, 10);
// Auto hide
if (duration > 0) {
setTimeout(() => {
this.hide(notification);
}, duration);
}
return notification;
},
createNotification: function(message, type) {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
const typeIcons = {
'success': 'check-circle',
'error': 'exclamation-circle',
'warning': 'exclamation-triangle',
'info': 'info-circle'
};
notification.innerHTML = `
<div class="notification-content">
<i class="fas fa-${typeIcons[type] || 'info-circle'} notification-icon"></i>
<span class="notification-message">${message}</span>
<button class="notification-close" onclick="ThrillWiki.notifications.hide(this.closest('.notification'))">
<i class="fas fa-times"></i>
</button>
</div>
`;
return notification;
},
hide: function(notification) {
notification.classList.add('hide');
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
},
setupAutoHide: function() {
// Auto-hide notifications on page navigation
if (window.htmx) {
document.addEventListener('htmx:beforeRequest', () => {
const notifications = this.container.querySelectorAll('.notification');
notifications.forEach(notification => {
this.hide(notification);
});
});
}
}
};
// Enhanced scroll effects
TW.scroll = {
init: function() {
this.setupParallax();
this.setupRevealAnimations();
this.setupScrollToTop();
},
setupParallax: function() {
const parallaxElements = document.querySelectorAll('[data-parallax]');
if (parallaxElements.length > 0) {
const handleScroll = TW.utils.throttle(() => {
const scrolled = window.pageYOffset;
parallaxElements.forEach(element => {
const speed = parseFloat(element.dataset.parallax) || 0.5;
const yPos = -(scrolled * speed);
element.style.transform = `translateY(${yPos}px)`;
});
}, 16); // ~60fps
window.addEventListener('scroll', handleScroll);
}
},
setupRevealAnimations: function() {
const revealElements = document.querySelectorAll('[data-reveal]');
if (revealElements.length > 0) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target;
const animationType = element.dataset.reveal || 'fadeIn';
const delay = parseInt(element.dataset.revealDelay) || 0;
setTimeout(() => {
element.classList.add('revealed');
if (TW.animations[animationType]) {
TW.animations[animationType](element);
}
}, delay);
observer.unobserve(element);
}
});
}, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
});
revealElements.forEach(element => {
observer.observe(element);
});
}
},
setupScrollToTop: function() {
const scrollToTopBtn = document.createElement('button');
scrollToTopBtn.id = 'scroll-to-top';
scrollToTopBtn.className = 'fixed bottom-8 right-8 w-12 h-12 bg-thrill-primary text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-300 opacity-0 pointer-events-none z-40';
scrollToTopBtn.innerHTML = '<i class="fas fa-arrow-up"></i>';
scrollToTopBtn.setAttribute('aria-label', 'Scroll to top');
document.body.appendChild(scrollToTopBtn);
const handleScroll = TW.utils.throttle(() => {
if (window.pageYOffset > 300) {
scrollToTopBtn.classList.remove('opacity-0', 'pointer-events-none');
scrollToTopBtn.classList.add('opacity-100');
} else {
scrollToTopBtn.classList.add('opacity-0', 'pointer-events-none');
scrollToTopBtn.classList.remove('opacity-100');
}
}, 100);
window.addEventListener('scroll', handleScroll);
scrollToTopBtn.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
}
};
// Enhanced form handling
TW.forms = {
init: function() {
this.setupFormValidation();
this.setupFormAnimations();
this.setupFileUploads();
},
setupFormValidation: function() {
const forms = document.querySelectorAll('form[data-validate]');
forms.forEach(form => {
form.addEventListener('submit', (e) => {
if (!this.validateForm(form)) {
e.preventDefault();
TW.animations.shake(form);
}
});
// Real-time validation
const inputs = form.querySelectorAll('input, textarea, select');
inputs.forEach(input => {
input.addEventListener('blur', () => {
this.validateField(input);
});
});
});
},
validateForm: function(form) {
let isValid = true;
const inputs = form.querySelectorAll('input[required], textarea[required], select[required]');
inputs.forEach(input => {
if (!this.validateField(input)) {
isValid = false;
}
});
return isValid;
},
validateField: function(field) {
const value = field.value.trim();
const isRequired = field.hasAttribute('required');
const type = field.type;
let isValid = true;
let errorMessage = '';
// Required validation
if (isRequired && !value) {
isValid = false;
errorMessage = 'This field is required';
}
// Type-specific validation
if (value && type === 'email') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
isValid = false;
errorMessage = 'Please enter a valid email address';
}
}
// Update field appearance
this.updateFieldValidation(field, isValid, errorMessage);
return isValid;
},
updateFieldValidation: function(field, isValid, errorMessage) {
const fieldGroup = field.closest('.form-group');
if (!fieldGroup) return;
// Remove existing error states
field.classList.remove('form-input-error');
const existingError = fieldGroup.querySelector('.form-error');
if (existingError) {
existingError.remove();
}
if (!isValid) {
field.classList.add('form-input-error');
const errorElement = document.createElement('div');
errorElement.className = 'form-error';
errorElement.textContent = errorMessage;
fieldGroup.appendChild(errorElement);
TW.animations.slideInUp(errorElement, 200);
}
}
};
// Initialize all modules
TW.init = function() {
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this.initModules.bind(this));
} else {
this.initModules();
}
};
TW.initModules = function() {
console.log('🎢 ThrillWiki Enhanced JavaScript initialized');
// Initialize all modules
TW.search.init();
TW.cards.init();
TW.notifications.init();
TW.scroll.init();
TW.forms.init();
// Setup HTMX enhancements
if (window.htmx) {
this.setupHTMXEnhancements();
}
// Setup global error handling
this.setupErrorHandling();
};
TW.setupHTMXEnhancements = function() {
// Global HTMX configuration
htmx.config.globalViewTransitions = true;
htmx.config.scrollBehavior = 'smooth';
// Enhanced loading states
document.addEventListener('htmx:beforeRequest', (e) => {
const target = e.target;
target.classList.add('htmx-request');
});
document.addEventListener('htmx:afterRequest', (e) => {
const target = e.target;
target.classList.remove('htmx-request');
});
// Re-initialize components after HTMX swaps
document.addEventListener('htmx:afterSwap', (e) => {
// Re-initialize cards in the swapped content
const newCards = e.detail.target.querySelectorAll('.card-park, .card-ride, .card-feature');
if (newCards.length > 0) {
TW.cards.setupCardHovers();
}
// Re-initialize forms
const newForms = e.detail.target.querySelectorAll('form[data-validate]');
if (newForms.length > 0) {
TW.forms.setupFormValidation();
}
});
};
TW.setupErrorHandling = function() {
window.addEventListener('error', (e) => {
console.error('ThrillWiki Error:', e.error);
// Could send to error tracking service here
});
window.addEventListener('unhandledrejection', (e) => {
console.error('ThrillWiki Promise Rejection:', e.reason);
// Could send to error tracking service here
});
};
// Auto-initialize
TW.init();
})(window.ThrillWiki);
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = window.ThrillWiki;
}

View File

@@ -48,7 +48,6 @@
<!-- Preload Critical Resources -->
{% block critical_resources %}
<link rel="preload" href="{% static 'css/tailwind.css' %}" as="style" />
<link rel="preload" href="{% static 'js/alpine.min.js' %}?v={{ version|default:'1.0' }}" as="script" />
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" as="style" />
{% endblock %}
@@ -62,10 +61,10 @@
/>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js" integrity="sha384-yWakaGAFicqusuwOYEmoRjLNOC+6OFsdmwC2lbGQaRELtuVEqNzt11c2J711DeCZ" crossorigin="anonymous"></script>
<!-- Alpine.js (must load after components) -->
<script defer src="{% static 'js/alpine.min.js' %}?v={{ version|default:'1.0' }}"></script>
<script src="//unpkg.com/alpinejs" defer></script>
<!-- Tailwind CSS -->
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
@@ -172,67 +171,49 @@
<!-- Global Toast Container -->
<c-toast_container />
<!-- AlpineJS Components and Stores (Inline) -->
<!-- AlpineJS Global Configuration (Compliant with HTMX + AlpineJS Only Rule) -->
<script>
// Global Alpine.js stores and components
document.addEventListener('alpine:init', () => {
// Global Store for App State
// Configure HTMX 2.x globally with proper defaults
htmx.config.globalViewTransitions = true;
// HTMX 2.x Migration: Maintain 1.x behavior for smooth scrolling
htmx.config.scrollBehavior = 'smooth';
// HTMX 2.x Migration: Keep DELETE requests using form-encoded body (like 1.x)
htmx.config.methodsThatUseUrlParams = ["get"];
// HTMX 2.x Migration: Allow cross-domain requests (like 1.x)
htmx.config.selfRequestsOnly = false;
// Enhanced HTMX event handling for better UX
document.body.addEventListener('htmx:configRequest', function(evt) {
// Add CSRF token to all HTMX requests
const csrfToken = document.querySelector('meta[name="csrf-token"]');
if (csrfToken) {
evt.detail.headers['X-CSRFToken'] = csrfToken.getAttribute('content');
}
});
// Initialize Alpine stores
Alpine.store('app', {
user: null,
theme: localStorage.getItem('theme') || 'system',
searchQuery: '',
notifications: [],
setUser(user) {
this.user = user;
},
setTheme(theme) {
this.theme = theme;
localStorage.setItem('theme', theme);
},
addNotification(notification) {
this.notifications.push({
id: Date.now(),
...notification
});
},
removeNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
}
notifications: []
});
// Global Toast Store
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
};
const toast = { id, message, type, visible: true, progress: 100 };
this.toasts.push(toast);
if (duration > 0) {
const interval = setInterval(() => {
toast.progress -= (100 / (duration / 100));
if (toast.progress <= 0) {
clearInterval(interval);
this.hide(id);
}
}, 100);
setTimeout(() => this.hide(id), duration);
}
return id;
},
hide(id) {
const toast = this.toasts.find(t => t.id === id);
if (toast) {
@@ -241,276 +222,8 @@
this.toasts = this.toasts.filter(t => t.id !== id);
}, 300);
}
},
success(message, duration = 5000) {
return this.show(message, 'success', duration);
},
error(message, duration = 7000) {
return this.show(message, 'error', duration);
},
warning(message, duration = 6000) {
return this.show(message, 'warning', duration);
},
info(message, duration = 5000) {
return this.show(message, 'info', duration);
}
});
// Theme Toggle Component
Alpine.data('themeToggle', () => ({
theme: localStorage.getItem('theme') || 'system',
init() {
this.updateTheme();
// Watch for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.theme === 'system') {
this.updateTheme();
}
});
},
toggleTheme() {
const themes = ['light', 'dark', 'system'];
const currentIndex = themes.indexOf(this.theme);
this.theme = themes[(currentIndex + 1) % themes.length];
localStorage.setItem('theme', this.theme);
this.updateTheme();
},
updateTheme() {
const root = document.documentElement;
if (this.theme === 'dark' ||
(this.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}
}));
// Modal Component
Alpine.data('modal', (initialOpen = false) => ({
open: initialOpen,
show() {
this.open = true;
document.body.style.overflow = 'hidden';
},
hide() {
this.open = false;
document.body.style.overflow = '';
},
toggle() {
if (this.open) {
this.hide();
} else {
this.show();
}
}
}));
// Dropdown Component
Alpine.data('dropdown', (initialOpen = false) => ({
open: initialOpen,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
},
show() {
this.open = true;
}
}));
// Search Component - HTMX-based (NO FETCH API)
Alpine.data('searchComponent', () => ({
query: '',
loading: false,
showResults: false,
init() {
// Listen for HTMX events
this.$el.addEventListener('htmx:beforeRequest', () => {
this.loading = true;
});
this.$el.addEventListener('htmx:afterRequest', () => {
this.loading = false;
});
this.$el.addEventListener('htmx:afterSettle', () => {
const resultsContainer = document.getElementById('search-results');
this.showResults = resultsContainer && resultsContainer.children.length > 0;
});
},
handleInput() {
if (this.query.length < 2) {
this.showResults = false;
const resultsContainer = document.getElementById('search-results');
if (resultsContainer) {
resultsContainer.innerHTML = '';
}
return;
}
// HTMX will handle the actual search via hx-trigger
},
selectResult(url) {
window.location.href = url;
this.showResults = false;
this.query = '';
},
clearSearch() {
this.query = '';
this.showResults = false;
const resultsContainer = document.getElementById('search-results');
if (resultsContainer) {
resultsContainer.innerHTML = '';
}
}
}));
// Browse Menu Component
Alpine.data('browseMenu', () => ({
open: false,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
}
}));
// Mobile Menu Component
Alpine.data('mobileMenu', () => ({
open: false,
toggle() {
this.open = !this.open;
if (this.open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
},
close() {
this.open = false;
document.body.style.overflow = '';
}
}));
// User Menu Component
Alpine.data('userMenu', () => ({
open: false,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
}
}));
// Auth Modal Component
Alpine.data('authModal', (defaultMode = 'login') => ({
open: false,
mode: defaultMode,
showPassword: false,
socialProviders: [
{id: 'google', name: 'Google', auth_url: '/accounts/google/login/'},
{id: 'discord', name: 'Discord', auth_url: '/accounts/discord/login/'}
],
loginForm: {
username: '',
password: ''
},
loginLoading: false,
loginError: '',
registerForm: {
first_name: '',
last_name: '',
email: '',
username: '',
password1: '',
password2: ''
},
registerLoading: false,
registerError: '',
init() {
this.$watch('open', (value) => {
if (value) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
this.resetForms();
}
});
},
show(mode = 'login') {
this.mode = mode;
this.open = true;
},
close() {
this.open = false;
},
switchToLogin() {
this.mode = 'login';
this.resetForms();
},
switchToRegister() {
this.mode = 'register';
this.resetForms();
},
resetForms() {
this.loginForm = { username: '', password: '' };
this.registerForm = {
first_name: '',
last_name: '',
email: '',
username: '',
password1: '',
password2: ''
};
this.loginError = '';
this.registerError = '';
this.showPassword = false;
},
getCSRFToken() {
const token = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.querySelector('meta[name=csrf-token]')?.getAttribute('content') ||
document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
return token || '';
}
}));
});
</script>

View File

@@ -41,9 +41,9 @@
<div class="hidden lg:flex items-center space-x-8">
<!-- Main Navigation Links -->
<div class="flex items-center space-x-6">
<a href="{% url 'parks:list' %}"
<a href="{% url 'parks:park_list' %}"
class="nav-link group relative"
hx-get="{% url 'parks:list' %}"
hx-get="{% url 'parks:park_list' %}"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
<i class="fas fa-map-marked-alt mr-2 text-thrill-primary"></i>
@@ -51,9 +51,9 @@
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-thrill-primary to-purple-500 transition-all duration-300 group-hover:w-full"></span>
</a>
<a href="{% url 'rides:list' %}"
<a href="{% url 'rides:global_ride_list' %}"
class="nav-link group relative"
hx-get="{% url 'rides:list' %}"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
<i class="fas fa-rocket mr-2 text-thrill-secondary"></i>
@@ -360,14 +360,14 @@
<!-- Mobile Navigation Links -->
<div class="space-y-4">
<a href="{% url 'parks:list' %}"
<a href="{% url 'parks:park_list' %}"
class="flex items-center p-3 rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-700/50 transition-colors"
@click="isOpen = false">
<i class="fas fa-map-marked-alt mr-3 text-thrill-primary"></i>
<span class="font-medium">Parks</span>
</a>
<a href="{% url 'rides:list' %}"
<a href="{% url 'rides:global_ride_list' %}"
class="flex items-center p-3 rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-700/50 transition-colors"
@click="isOpen = false">
<i class="fas fa-rocket mr-3 text-thrill-secondary"></i>

View File

@@ -67,9 +67,11 @@
{{ search_form.lng }}
<div class="flex gap-2">
<button type="button"
id="use-my-location"
class="flex-1 px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300">
📍 Use My Location
x-data="geolocationButton"
@click="getLocation()"
:disabled="loading"
class="flex-1 px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 disabled:opacity-50">
<span x-text="buttonText">📍 Use My Location</span>
</button>
</div>
</div>
@@ -290,43 +292,58 @@
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Geolocation support
const useLocationBtn = document.getElementById('use-my-location');
const latInput = document.getElementById('lat-input');
const lngInput = document.getElementById('lng-input');
const locationInput = document.getElementById('location-input');
if (useLocationBtn && 'geolocation' in navigator) {
useLocationBtn.addEventListener('click', function() {
this.textContent = '📍 Getting location...';
this.disabled = true;
document.addEventListener('alpine:init', () => {
// Geolocation Button Component
Alpine.data('geolocationButton', () => ({
loading: false,
buttonText: '📍 Use My Location',
init() {
// Hide button if geolocation is not supported
if (!('geolocation' in navigator)) {
this.$el.style.display = 'none';
}
},
getLocation() {
if (!('geolocation' in navigator)) return;
this.loading = true;
this.buttonText = '📍 Getting location...';
navigator.geolocation.getCurrentPosition(
function(position) {
latInput.value = position.coords.latitude;
lngInput.value = position.coords.longitude;
locationInput.value = `${position.coords.latitude}, ${position.coords.longitude}`;
useLocationBtn.textContent = '✅ Location set';
(position) => {
// Find form inputs
const form = this.$el.closest('form');
const latInput = form.querySelector('input[name="lat"]');
const lngInput = form.querySelector('input[name="lng"]');
const locationInput = form.querySelector('input[name="location"]');
if (latInput) latInput.value = position.coords.latitude;
if (lngInput) lngInput.value = position.coords.longitude;
if (locationInput) {
locationInput.value = `${position.coords.latitude}, ${position.coords.longitude}`;
}
this.buttonText = '✅ Location set';
this.loading = false;
setTimeout(() => {
useLocationBtn.textContent = '📍 Use My Location';
useLocationBtn.disabled = false;
this.buttonText = '📍 Use My Location';
}, 2000);
},
function(error) {
useLocationBtn.textContent = '❌ Location failed';
(error) => {
console.error('Geolocation error:', error);
this.buttonText = '❌ Location failed';
this.loading = false;
setTimeout(() => {
useLocationBtn.textContent = '📍 Use My Location';
useLocationBtn.disabled = false;
this.buttonText = '📍 Use My Location';
}, 2000);
}
);
});
} else if (useLocationBtn) {
useLocationBtn.style.display = 'none';
}
}
}));
});
</script>
<script src="{% static 'js/location-search.js' %}"></script>
{% endblock %}

View File

@@ -41,9 +41,9 @@
<!-- Right side: View switching buttons -->
<div class="flex items-center gap-2">
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1" x-data="{ viewMode: '{{ request.GET.view_mode|default:'grid' }}' }">
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1" x-data="searchViewSwitcher">
<button type="button"
@click="viewMode = 'grid'; switchView('grid')"
@click="switchView('grid')"
:class="viewMode === 'grid' ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -51,7 +51,7 @@
</svg>
</button>
<button type="button"
@click="viewMode = 'list'; switchView('list')"
@click="switchView('list')"
:class="viewMode === 'list' ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -64,7 +64,7 @@
</div>
<!-- Results Container -->
<div id="results-container" class="divide-y divide-gray-200 dark:divide-gray-700" x-data="{ viewMode: '{{ request.GET.view_mode|default:'grid' }}' }">
<div id="results-container" class="divide-y divide-gray-200 dark:divide-gray-700" x-data="searchResults">
<!-- Grid View -->
<div x-show="viewMode === 'grid'" class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -235,64 +235,64 @@
</div>
</div>
{# Include required scripts #}
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://unpkg.com/unpoly@3/unpoly.min.js"></script>
<script>
// View switching functionality
function switchView(mode) {
// Update URL parameter
const url = new URL(window.location);
url.searchParams.set('view_mode', mode);
// Update the URL without reloading
window.history.pushState({}, '', url);
// Store preference in localStorage
localStorage.setItem('parkViewMode', mode);
}
document.addEventListener('alpine:init', () => {
// Search View Switcher Component
Alpine.data('searchViewSwitcher', () => ({
viewMode: '{{ request.GET.view_mode|default:"grid" }}',
init() {
// Initialize view mode from URL or localStorage
const urlParams = new URLSearchParams(window.location.search);
const urlViewMode = urlParams.get('view_mode');
const savedViewMode = localStorage.getItem('parkViewMode');
this.viewMode = urlViewMode || savedViewMode || 'grid';
// Set initial view mode in URL if not present
if (!urlViewMode) {
const url = new URL(window.location);
url.searchParams.set('view_mode', this.viewMode);
window.history.replaceState({}, '', url);
}
},
switchView(mode) {
this.viewMode = mode;
// Update URL parameter
const url = new URL(window.location);
url.searchParams.set('view_mode', mode);
window.history.pushState({}, '', url);
// Store preference in localStorage
localStorage.setItem('parkViewMode', mode);
// Update results container view mode
this.$dispatch('view-mode-changed', { mode });
}
}));
// Initialize view mode from URL or localStorage
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const urlViewMode = urlParams.get('view_mode');
const savedViewMode = localStorage.getItem('parkViewMode');
const defaultViewMode = urlViewMode || savedViewMode || 'grid';
// Set initial view mode
if (!urlViewMode) {
const url = new URL(window.location);
url.searchParams.set('view_mode', defaultViewMode);
window.history.replaceState({}, '', url);
}
// Search Results Component
Alpine.data('searchResults', () => ({
viewMode: '{{ request.GET.view_mode|default:"grid" }}',
init() {
// Listen for view mode changes
this.$el.addEventListener('view-mode-changed', (event) => {
this.viewMode = event.detail.mode;
});
}
}));
});
// Enhanced search functionality
// Enhanced search functionality with HTMX integration
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.querySelector('input[name="search"]');
if (searchInput) {
let searchTimeout;
// Preserve view mode in search requests
searchInput.addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
// Get current view mode
const currentViewMode = new URLSearchParams(window.location.search).get('view_mode') || 'grid';
// Add view mode to the HTMX request
const currentUrl = new URL(e.target.getAttribute('hx-get'), window.location.origin);
currentUrl.searchParams.set('view_mode', currentViewMode);
currentUrl.searchParams.set('search', e.target.value);
// Update the hx-get attribute
e.target.setAttribute('hx-get', currentUrl.pathname + currentUrl.search);
// Trigger the HTMX request
htmx.trigger(e.target, 'input');
}, 500);
searchInput.addEventListener('htmx:configRequest', function(event) {
const currentViewMode = new URLSearchParams(window.location.search).get('view_mode') || 'grid';
event.detail.parameters.view_mode = currentViewMode;
});
}
});

View File

@@ -98,46 +98,67 @@
</div>
<!-- Featured Parks Grid -->
<div class="grid-auto-fit-lg"
hx-get="/api/parks/featured/"
hx-trigger="revealed"
hx-swap="innerHTML">
<!-- Loading Skeletons -->
<div class="grid-auto-fit-lg">
<!-- Static placeholder content -->
<div class="card hover-lift">
<div class="loading-skeleton aspect-video rounded-t-2xl"></div>
<div class="p-6 space-y-4">
<div class="loading-skeleton h-6 w-3/4 rounded"></div>
<div class="loading-skeleton h-4 w-full rounded"></div>
<div class="loading-skeleton h-4 w-2/3 rounded"></div>
<div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-primary to-purple-500 flex items-center justify-center">
<i class="fas fa-map-marked-alt text-4xl text-white"></i>
</div>
<div class="p-6">
<h3 class="text-xl font-bold mb-2">Explore Amazing Parks</h3>
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
Discover incredible theme parks from around the world with detailed guides and insider tips.
</p>
<div class="flex justify-between items-center">
<div class="loading-skeleton h-6 w-20 rounded-full"></div>
<div class="loading-skeleton h-8 w-24 rounded-lg"></div>
<span class="badge badge-primary">Featured</span>
<button class="btn-primary btn-sm"
hx-get="/parks/"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
View All
</button>
</div>
</div>
</div>
<div class="card hover-lift">
<div class="loading-skeleton aspect-video rounded-t-2xl"></div>
<div class="p-6 space-y-4">
<div class="loading-skeleton h-6 w-3/4 rounded"></div>
<div class="loading-skeleton h-4 w-full rounded"></div>
<div class="loading-skeleton h-4 w-2/3 rounded"></div>
<div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-secondary to-red-500 flex items-center justify-center">
<i class="fas fa-rocket text-4xl text-white"></i>
</div>
<div class="p-6">
<h3 class="text-xl font-bold mb-2">Thrilling Rides</h3>
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
From heart-pounding roller coasters to magical dark rides, find your next adventure.
</p>
<div class="flex justify-between items-center">
<div class="loading-skeleton h-6 w-20 rounded-full"></div>
<div class="loading-skeleton h-8 w-24 rounded-lg"></div>
<span class="badge badge-secondary">Popular</span>
<button class="btn-secondary btn-sm"
hx-get="/rides/"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
Explore
</button>
</div>
</div>
</div>
<div class="card hover-lift">
<div class="loading-skeleton aspect-video rounded-t-2xl"></div>
<div class="p-6 space-y-4">
<div class="loading-skeleton h-6 w-3/4 rounded"></div>
<div class="loading-skeleton h-4 w-full rounded"></div>
<div class="loading-skeleton h-4 w-2/3 rounded"></div>
<div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-success to-teal-500 flex items-center justify-center">
<i class="fas fa-search text-4xl text-white"></i>
</div>
<div class="p-6">
<h3 class="text-xl font-bold mb-2">Advanced Search</h3>
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
Find exactly what you're looking for with our powerful search and filtering tools.
</p>
<div class="flex justify-between items-center">
<div class="loading-skeleton h-6 w-20 rounded-full"></div>
<div class="loading-skeleton h-8 w-24 rounded-lg"></div>
<span class="badge badge-success">Tools</span>
<button class="btn-success btn-sm"
hx-get="/search/"
hx-target="#main-content"
hx-swap="innerHTML transition:true">
Search
</button>
</div>
</div>
</div>
@@ -333,47 +354,8 @@
</div>
</section>
<!-- Enhanced JavaScript for Interactions -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Enable HTMX view transitions globally
htmx.config.globalViewTransitions = true;
// Add staggered animations to elements
const animatedElements = document.querySelectorAll('.slide-in-up');
animatedElements.forEach((el, index) => {
el.style.animationDelay = `${index * 0.1}s`;
});
// Parallax effect for hero background elements
window.addEventListener('scroll', () => {
const scrolled = window.pageYOffset;
const parallaxElements = document.querySelectorAll('.hero .absolute');
parallaxElements.forEach((el, index) => {
const speed = 0.5 + (index * 0.1);
el.style.transform = `translateY(${scrolled * speed}px)`;
});
});
// Intersection Observer for reveal animations
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('fade-in');
}
});
}, observerOptions);
// Observe all cards for reveal animations
document.querySelectorAll('.card, .card-feature, .card-park, .card-ride').forEach(card => {
observer.observe(card);
});
});
</script>
<!-- HTMX + AlpineJS Implementation (NO Custom JavaScript) -->
<div x-data="homePageAnimations" x-init="init()">
<!-- Animation triggers handled by AlpineJS -->
</div>
{% endblock %}

View File

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

View File

@@ -198,310 +198,122 @@
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script>
// Map initialization and management
class ThrillWikiMap {
constructor(containerId, options = {}) {
this.containerId = containerId;
this.options = {
center: [39.8283, -98.5795], // Center of USA
zoom: 4,
enableClustering: true,
...options
};
this.map = null;
this.markers = new L.MarkerClusterGroup();
this.currentData = [];
this.init();
}
init() {
// Initialize the map
this.map = L.map(this.containerId, {
center: this.options.center,
zoom: this.options.zoom,
zoomControl: false
});
document.addEventListener('alpine:init', () => {
Alpine.data('universalMap', () => ({
map: null,
markers: {},
markerCluster: null,
// Add custom zoom control
L.control.zoom({
position: 'bottomright'
}).addTo(this.map);
init() {
this.initMap();
this.setupFilters();
},
// Add tile layers with dark mode support
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
className: 'map-tiles'
});
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO',
className: 'map-tiles-dark'
});
// Set initial tiles based on theme
if (document.documentElement.classList.contains('dark')) {
darkTiles.addTo(this.map);
} else {
lightTiles.addTo(this.map);
}
// Listen for theme changes
this.observeThemeChanges(lightTiles, darkTiles);
// Add markers cluster group
this.map.addLayer(this.markers);
// Bind map events
this.bindEvents();
// Load initial data
this.loadMapData();
}
observeThemeChanges(lightTiles, darkTiles) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
if (document.documentElement.classList.contains('dark')) {
this.map.removeLayer(lightTiles);
this.map.addLayer(darkTiles);
} else {
this.map.removeLayer(darkTiles);
this.map.addLayer(lightTiles);
initMap() {
// Initialize Leaflet map
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);
// Initialize marker cluster group
this.markerCluster = L.markerClusterGroup({
iconCreateFunction: (cluster) => {
const count = cluster.getChildCount();
return L.divIcon({
html: `<div class="cluster-marker-inner">${count}</div>`,
className: 'cluster-marker',
iconSize: [40, 40]
});
}
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
bindEvents() {
// Update map when bounds change
this.map.on('moveend zoomend', () => {
this.updateMapBounds();
});
// Handle filter form changes
document.getElementById('map-filters').addEventListener('htmx:afterRequest', (event) => {
if (event.detail.successful) {
});
this.map.addLayer(this.markerCluster);
this.loadMapData();
}
});
}
loadMapData() {
try {
document.getElementById('map-loading').style.display = 'flex';
const formData = new FormData(document.getElementById('map-filters'));
const queryParams = {};
// Add form data to params
for (let [key, value] of formData.entries()) {
queryParams[key] = value;
},
setupFilters() {
// Handle filter pill clicks
document.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', () => {
const checkbox = pill.querySelector('input[type="checkbox"]');
checkbox.checked = !checkbox.checked;
pill.classList.toggle('active', checkbox.checked);
this.updateFilters();
});
// Set initial state
const checkbox = pill.querySelector('input[type="checkbox"]');
pill.classList.toggle('active', checkbox.checked);
});
},
loadMapData() {
// Load initial map data via HTMX
const form = document.getElementById('map-filters');
if (form) {
htmx.trigger(form, 'submit');
}
},
updateFilters() {
// Trigger HTMX filter update
const form = document.getElementById('map-filters');
if (form) {
htmx.trigger(form, 'change');
}
},
addMarker(data) {
const icon = this.getMarkerIcon(data.type);
const marker = L.marker([data.lat, data.lng], { icon })
.bindPopup(this.createPopupContent(data));
// Add map bounds
const bounds = this.map.getBounds();
queryParams.north = bounds.getNorth();
queryParams.south = bounds.getSouth();
queryParams.east = bounds.getEast();
queryParams.west = bounds.getWest();
queryParams.zoom = this.map.getZoom();
this.markerCluster.addLayer(marker);
this.markers[data.id] = marker;
},
getMarkerIcon(type) {
const icons = {
'park': '🎢',
'ride': '🎠',
'company': '🏢',
'default': '📍'
};
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-get', '{{ map_api_urls.locations }}');
tempForm.setAttribute('hx-vals', JSON.stringify(queryParams));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
tempForm.addEventListener('htmx:afterRequest', (event) => {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.status === 'success') {
this.updateMarkers(data.data);
} else {
console.error('Map data error:', data.message);
}
} catch (error) {
console.error('Failed to load map data:', error);
} finally {
document.getElementById('map-loading').style.display = 'none';
document.body.removeChild(tempForm);
}
return L.divIcon({
html: `<div class="location-marker-inner">${icons[type] || icons.default}</div>`,
className: 'location-marker',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
tempForm.addEventListener('htmx:error', (event) => {
console.error('Failed to load map data:', event.detail.error);
document.getElementById('map-loading').style.display = 'none';
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
} catch (error) {
console.error('Failed to load map data:', error);
document.getElementById('map-loading').style.display = 'none';
}
}
updateMarkers(data) {
// Clear existing markers
this.markers.clearLayers();
},
// Add location markers
if (data.locations) {
data.locations.forEach(location => {
this.addLocationMarker(location);
});
}
// Add cluster markers
if (data.clusters) {
data.clusters.forEach(cluster => {
this.addClusterMarker(cluster);
});
}
}
addLocationMarker(location) {
const icon = this.getLocationIcon(location.type);
const marker = L.marker([location.latitude, location.longitude], { icon });
// Create popup content
const popupContent = this.createPopupContent(location);
marker.bindPopup(popupContent);
// Add click handler for detailed view
marker.on('click', () => {
this.showLocationDetails(location.type, location.id);
});
this.markers.addLayer(marker);
}
addClusterMarker(cluster) {
const marker = L.marker([cluster.latitude, cluster.longitude], {
icon: L.divIcon({
className: 'cluster-marker',
html: `<div class="cluster-marker-inner">${cluster.count}</div>`,
iconSize: [40, 40]
})
});
marker.bindPopup(`${cluster.count} locations in this area`);
this.markers.addLayer(marker);
}
getLocationIcon(type) {
const iconMap = {
'park': '🎢',
'ride': '🎠',
'company': '🏢',
'generic': '📍'
};
return L.divIcon({
className: 'location-marker',
html: `<div class="location-marker-inner">${iconMap[type] || '📍'}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
}
createPopupContent(location) {
return `
<div class="location-info-popup">
<h3>${location.name}</h3>
${location.formatted_location ? `<p><i class="fas fa-map-marker-alt mr-1"></i>${location.formatted_location}</p>` : ''}
${location.operator ? `<p><i class="fas fa-building mr-1"></i>${location.operator}</p>` : ''}
${location.ride_count ? `<p><i class="fas fa-rocket mr-1"></i>${location.ride_count} rides</p>` : ''}
<div class="mt-2">
<button onclick="thrillwikiMap.showLocationDetails('${location.type}', ${location.id})"
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
View Details
</button>
createPopupContent(data) {
return `
<div class="location-info-popup">
<h3>${data.name}</h3>
${data.description ? `<p>${data.description}</p>` : ''}
${data.location ? `<p><strong>Location:</strong> ${data.location}</p>` : ''}
${data.url ? `<p><a href="${data.url}" class="text-blue-600 hover:text-blue-800">View Details</a></p>` : ''}
</div>
</div>
`;
}
showLocationDetails(type, id) {
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-get', `{% url 'maps:htmx_location_detail' 'TYPE' 0 %}`.replace('TYPE', type).replace('0', id));
tempForm.setAttribute('hx-target', '#location-modal');
tempForm.setAttribute('hx-swap', 'innerHTML');
tempForm.setAttribute('hx-trigger', 'submit');
`;
},
tempForm.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.successful) {
document.getElementById('location-modal').classList.remove('hidden');
}
document.body.removeChild(tempForm);
});
tempForm.addEventListener('htmx:error', (event) => {
console.error('Failed to load location details:', event.detail.error);
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}
updateMapBounds() {
// This could trigger an HTMX request to update data based on new bounds
// For now, we'll just reload data when the map moves significantly
clearTimeout(this.boundsUpdateTimeout);
this.boundsUpdateTimeout = setTimeout(() => {
this.loadMapData();
}, 1000);
}
}
// Initialize map when page loads
document.addEventListener('DOMContentLoaded', function() {
window.thrillwikiMap = new ThrillWikiMap('map-container', {
{% if initial_bounds %}
center: [{{ initial_bounds.north|add:initial_bounds.south|floatformat:6|div:2 }}, {{ initial_bounds.east|add:initial_bounds.west|floatformat:6|div:2 }}],
{% endif %}
enableClustering: {{ enable_clustering|yesno:"true,false" }}
});
// Handle filter pill toggles
document.querySelectorAll('.filter-pill').forEach(pill => {
const checkbox = pill.querySelector('input[type="checkbox"]');
// Set initial state
if (checkbox.checked) {
pill.classList.add('active');
clearMarkers() {
this.markerCluster.clearLayers();
this.markers = {};
}
pill.addEventListener('click', () => {
checkbox.checked = !checkbox.checked;
pill.classList.toggle('active', checkbox.checked);
// Trigger form change
document.getElementById('map-filters').dispatchEvent(new Event('change'));
});
});
// Close modal handler
document.addEventListener('click', (e) => {
if (e.target.id === 'location-modal') {
document.getElementById('location-modal').classList.add('hidden');
}
});
}));
});
</script>
<!-- Map Component Container -->
<div x-data="universalMap" x-init="init()" style="display: none;"></div>
<style>
.cluster-marker {
background: transparent;

View File

@@ -145,7 +145,7 @@
{% endblock %}
{% block content %}
<div class="container max-w-6xl px-4 py-6 mx-auto">
<div class="container max-w-6xl px-4 py-6 mx-auto" x-data="moderationDashboard()" @retry-load="retryLoad()">
<div id="dashboard-content" class="relative transition-all duration-200">
{% block moderation_content %}
{% include "moderation/partials/dashboard_content.html" %}
@@ -169,7 +169,7 @@
There was a problem loading the content. Please try again.
</p>
<button class="px-4 py-2 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600"
onclick="window.location.reload()">
@click="$dispatch('retry-load')">
<i class="mr-2 fas fa-sync-alt"></i>
Retry
</button>
@@ -181,117 +181,155 @@
{% block extra_js %}
<script>
// HTMX Configuration and Enhancements
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
document.addEventListener('alpine:init', () => {
// Moderation Dashboard Component
Alpine.data('moderationDashboard', () => ({
showLoading: false,
errorMessage: null,
init() {
// HTMX Configuration
this.setupHTMXConfig();
this.setupEventListeners();
this.setupSearchDebouncing();
this.setupInfiniteScroll();
this.setupKeyboardNavigation();
},
setupHTMXConfig() {
document.body.addEventListener('htmx:configRequest', (evt) => {
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
},
setupEventListeners() {
// Enhanced HTMX Event Handlers
document.body.addEventListener('htmx:beforeRequest', (evt) => {
if (evt.detail.target.id === 'dashboard-content') {
this.showLoadingState();
}
});
// Loading and Error State Management
const dashboard = {
content: document.getElementById('dashboard-content'),
skeleton: document.getElementById('loading-skeleton'),
errorState: document.getElementById('error-state'),
errorMessage: document.getElementById('error-message'),
document.body.addEventListener('htmx:afterOnLoad', (evt) => {
if (evt.detail.target.id === 'dashboard-content') {
this.hideLoadingState();
this.resetFocus(evt.detail.target);
}
});
showLoading() {
this.content.setAttribute('aria-busy', 'true');
this.content.style.opacity = '0';
this.errorState.classList.add('hidden');
},
hideLoading() {
this.content.setAttribute('aria-busy', 'false');
this.content.style.opacity = '1';
},
showError(message) {
this.errorState.classList.remove('hidden');
this.errorMessage.textContent = message || 'There was a problem loading the content. Please try again.';
// Announce error to screen readers
this.errorMessage.setAttribute('role', 'alert');
}
};
// Enhanced HTMX Event Handlers
document.body.addEventListener('htmx:beforeRequest', function(evt) {
if (evt.detail.target.id === 'dashboard-content') {
dashboard.showLoading();
}
});
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
if (evt.detail.target.id === 'dashboard-content') {
dashboard.hideLoading();
// Reset focus for accessibility
const firstFocusable = evt.detail.target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) {
firstFocusable.focus();
}
}
});
document.body.addEventListener('htmx:responseError', function(evt) {
if (evt.detail.target.id === 'dashboard-content') {
dashboard.showError(evt.detail.error);
}
});
// Search Input Debouncing
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Apply debouncing to search inputs
document.querySelectorAll('[data-search]').forEach(input => {
const originalSearch = () => {
htmx.trigger(input, 'input');
};
const debouncedSearch = debounce(originalSearch, 300);
input.addEventListener('input', (e) => {
e.preventDefault();
debouncedSearch();
});
});
// Virtual Scrolling for Large Lists
const observerOptions = {
root: null,
rootMargin: '100px',
threshold: 0.1
};
const loadMoreContent = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting && !entry.target.classList.contains('loading')) {
entry.target.classList.add('loading');
htmx.trigger(entry.target, 'intersect');
}
});
};
const observer = new IntersectionObserver(loadMoreContent, observerOptions);
document.querySelectorAll('[data-infinite-scroll]').forEach(el => observer.observe(el));
// Keyboard Navigation Enhancement
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const openModals = document.querySelectorAll('[x-show="showNotes"]');
openModals.forEach(modal => {
const alpineData = modal.__x.$data;
if (alpineData.showNotes) {
alpineData.showNotes = false;
document.body.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.target.id === 'dashboard-content') {
this.showErrorState(evt.detail.error);
}
});
},
showLoadingState() {
const content = this.$el.querySelector('#dashboard-content');
if (content) {
content.setAttribute('aria-busy', 'true');
content.style.opacity = '0';
}
});
}
const errorState = this.$el.querySelector('#error-state');
if (errorState) {
errorState.classList.add('hidden');
}
},
hideLoadingState() {
const content = this.$el.querySelector('#dashboard-content');
if (content) {
content.setAttribute('aria-busy', 'false');
content.style.opacity = '1';
}
},
showErrorState(message) {
const errorState = this.$el.querySelector('#error-state');
const errorMessage = this.$el.querySelector('#error-message');
if (errorState) {
errorState.classList.remove('hidden');
}
if (errorMessage) {
errorMessage.textContent = message || 'There was a problem loading the content. Please try again.';
errorMessage.setAttribute('role', 'alert');
}
},
resetFocus(target) {
const firstFocusable = target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) {
firstFocusable.focus();
}
},
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
setupSearchDebouncing() {
const searchInputs = this.$el.querySelectorAll('[data-search]');
searchInputs.forEach(input => {
const originalSearch = () => {
htmx.trigger(input, 'input');
};
const debouncedSearch = this.debounce(originalSearch, 300);
input.addEventListener('input', (e) => {
e.preventDefault();
debouncedSearch();
});
});
},
setupInfiniteScroll() {
const observerOptions = {
root: null,
rootMargin: '100px',
threshold: 0.1
};
const loadMoreContent = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting && !entry.target.classList.contains('loading')) {
entry.target.classList.add('loading');
htmx.trigger(entry.target, 'intersect');
}
});
};
const observer = new IntersectionObserver(loadMoreContent, observerOptions);
const infiniteScrollElements = this.$el.querySelectorAll('[data-infinite-scroll]');
infiniteScrollElements.forEach(el => observer.observe(el));
},
setupKeyboardNavigation() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const openModals = this.$el.querySelectorAll('[x-show="showNotes"]');
openModals.forEach(modal => {
const alpineData = modal.__x?.$data;
if (alpineData && alpineData.showNotes) {
alpineData.showNotes = false;
}
});
}
});
},
retryLoad() {
window.location.reload();
}
}));
});
</script>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,14 +12,6 @@
{% endblock %}
{% block content %}
<script nonce="{{ request.csp_nonce }}">
document.addEventListener('alpine:init', () => {
Alpine.data('photoUploadModal', () => ({
show: false,
editingPhoto: { caption: '' }
}))
})
</script>
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
<!-- Action Buttons - Above header -->
@@ -141,7 +133,16 @@
<!-- Rest of the content remains unchanged -->
{% if park.photos.exists %}
<div class="p-optimized mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-optimized mb-8 bg-white rounded-lg shadow dark:bg-gray-800"
x-data="{
selectedPhoto: null,
showGallery: false,
currentIndex: 0,
photos: {{ park.photos.all|length }},
init() {
// Photo gallery initialization
}
}">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
{% include "media/partials/photo_display.html" with photos=park.photos.all content_type="parks.park" object_id=park.id %}
</div>
@@ -188,10 +189,12 @@
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Location</h2>
{% with location=park.location.first %}
{% if location.latitude is not None and location.longitude is not None %}
<div id="park-map" class="relative rounded-lg" style="z-index: 0;"
data-latitude="{{ location.latitude|default_if_none:'' }}"
data-longitude="{{ location.longitude|default_if_none:'' }}"
data-park-name="{{ park.name|escape }}"></div>
<div x-data="parkMap"
x-init="initMap({{ location.latitude }}, {{ location.longitude }}, '{{ park.name|escapejs }}')"
id="park-map"
class="relative rounded-lg h-64"
style="z-index: 0;">
</div>
{% else %}
<div class="relative rounded-lg p-4 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-map-marker-alt text-2xl mb-2"></i>
@@ -239,13 +242,7 @@
<!-- Photo Upload Modal -->
{% if perms.media.add_photo %}
<div x-cloak
x-data="{
show: false,
editingPhoto: null,
init() {
this.editingPhoto = { caption: '' };
}
}"
x-data="photoUploadModal"
@show-photo-upload.window="show = true; init()"
x-show="show"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50"
@@ -266,27 +263,39 @@
{% endblock %}
{% block extra_js %}
<!-- Photo Gallery Script -->
<script src="{% static 'js/photo-gallery.js' %}"></script>
<!-- Map Script (if location exists) -->
<!-- External libraries only (Leaflet for maps) -->
{% if park.location.exists %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="{% static 'js/park-map.js' %}"></script>
{% endif %}
<script nonce="{{ request.csp_nonce }}">
document.addEventListener('DOMContentLoaded', function() {
var mapElement = document.getElementById('park-map');
if (mapElement && mapElement.dataset.latitude && mapElement.dataset.longitude) {
var latitude = parseFloat(mapElement.dataset.latitude);
var longitude = parseFloat(mapElement.dataset.longitude);
var parkName = mapElement.dataset.parkName;
if (!isNaN(latitude) && !isNaN(longitude) && parkName) {
initParkMap(latitude, longitude, parkName);
<script>
document.addEventListener('alpine:init', () => {
// Photo Upload Modal Component
Alpine.data('photoUploadModal', () => ({
show: false,
editingPhoto: null,
init() {
this.editingPhoto = { caption: '' };
}
}
}));
// Park Map Component
{% if park.location.exists %}
Alpine.data('parkMap', () => ({
map: null,
initMap(lat, lng, parkName) {
if (typeof L !== 'undefined') {
this.map = L.map(this.$el).setView([lat, lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(this.map);
L.marker([lat, lng]).addTo(this.map)
.bindPopup(parkName)
.openPopup();
}
}
}));
{% endif %}
});
</script>
{% endif %}
{% endblock %}

View File

@@ -26,7 +26,7 @@
{% if is_edit %}Edit{% else %}Create{% endif %} Park
</h1>
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="parkForm">
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="parkFormData">
{% csrf_token %}
{# Basic Information #}
@@ -81,7 +81,10 @@
<div class="absolute top-0 right-0 p-2">
<button type="button"
class="p-2 text-white bg-red-600 rounded-full hover:bg-red-700"
@click="removePhoto('{{ photo.id }}')">
hx-delete="{% url 'media:photo_delete' photo.id %}"
hx-confirm="Are you sure you want to remove this photo?"
hx-target="closest .relative"
hx-swap="outerHTML">
<i class="fas fa-times"></i>
</button>
</div>
@@ -101,7 +104,7 @@
accept="image/*"
class="hidden"
x-ref="fileInput"
@change="handleFileSelect">
@change="handleFileSelect($event)">
<button type="button"
class="w-full px-4 py-2 text-gray-700 border-2 border-dashed rounded-lg dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
@click="$refs.fileInput.click()">
@@ -212,8 +215,9 @@
<div class="flex justify-end">
<button type="submit"
class="px-6 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800"
:disabled="uploading"
x-text="uploading ? 'Uploading...' : '{% if is_edit %}Save Changes{% else %}Create Park{% endif %}'">
:disabled="uploading">
<span x-show="!uploading">{% if is_edit %}Save Changes{% else %}Create Park{% endif %}</span>
<span x-show="uploading">Uploading...</span>
</button>
</div>
</form>
@@ -224,8 +228,8 @@
{% block extra_js %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
function parkForm() {
return {
document.addEventListener('alpine:init', () => {
Alpine.data('parkFormData', () => ({
previews: [],
uploading: false,
@@ -257,133 +261,8 @@ function parkForm() {
removePreview(index) {
this.previews.splice(index, 1);
},
uploadPhotos() {
if (!this.previews.length) return true;
this.uploading = true;
let allUploaded = true;
let uploadPromises = [];
for (let preview of this.previews) {
if (preview.uploaded || preview.error) continue;
preview.uploading = true;
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-post', '/photos/upload/');
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
tempForm.enctype = 'multipart/form-data';
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = document.querySelector('[name=csrfmiddlewaretoken]').value;
tempForm.appendChild(csrfInput);
// Add form data
const imageInput = document.createElement('input');
imageInput.type = 'file';
imageInput.name = 'image';
imageInput.files = this.createFileList([preview.file]);
tempForm.appendChild(imageInput);
const appLabelInput = document.createElement('input');
appLabelInput.type = 'hidden';
appLabelInput.name = 'app_label';
appLabelInput.value = 'parks';
tempForm.appendChild(appLabelInput);
const modelInput = document.createElement('input');
modelInput.type = 'hidden';
modelInput.name = 'model';
modelInput.value = 'park';
tempForm.appendChild(modelInput);
const objectIdInput = document.createElement('input');
objectIdInput.type = 'hidden';
objectIdInput.name = 'object_id';
objectIdInput.value = '{{ park.id }}';
tempForm.appendChild(objectIdInput);
// Track upload completion with event listeners
tempForm.addEventListener('htmx:afterRequest', (event) => {
try {
if (event.detail.xhr.status === 200) {
const result = JSON.parse(event.detail.xhr.responseText);
preview.uploading = false;
preview.uploaded = true;
} else {
throw new Error('Upload failed');
}
} catch (error) {
console.error('Upload failed:', error);
preview.uploading = false;
preview.error = true;
allUploaded = false;
}
// Track completion
completedUploads++;
if (completedUploads === totalUploads) {
this.uploading = false;
}
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}
// Initialize completion tracking
let completedUploads = 0;
const totalUploads = this.previews.filter(p => !p.uploaded && !p.error).length;
if (totalUploads === 0) {
this.uploading = false;
return true;
}
return true; // Return immediately, completion handled by event listeners
},
createFileList(files) {
const dt = new DataTransfer();
files.forEach(file => dt.items.add(file));
return dt.files;
},
removePhoto(photoId) {
if (confirm('Are you sure you want to remove this photo?')) {
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-delete', `/photos/${photoId}/delete/`);
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = document.querySelector('[name=csrfmiddlewaretoken]').value;
tempForm.appendChild(csrfInput);
tempForm.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.xhr.status === 200) {
window.location.reload();
}
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}
}
}
}
}));
});
</script>
{% endblock %}

View File

@@ -288,114 +288,21 @@
</div>
</div>
<!-- AlpineJS State Management -->
<script>
{# Enhanced Mobile-First AlpineJS State Management #}
function parkListState() {
return {
showFilters: window.innerWidth >= 1024, // Show on desktop by default
viewMode: '{{ view_mode }}',
searchQuery: '{{ search_query }}',
isLoading: false,
error: null,
init() {
// Handle responsive filter visibility with better mobile UX
this.handleResize();
window.addEventListener('resize', this.debounce(() => this.handleResize(), 250));
// Enhanced HTMX events with better mobile feedback
document.addEventListener('htmx:beforeRequest', () => {
this.setLoading(true);
this.error = null;
});
document.addEventListener('htmx:afterRequest', (event) => {
this.setLoading(false);
// Scroll to top of results on mobile after filter changes
if (window.innerWidth < 768 && event.detail.target?.id === 'park-results') {
this.scrollToResults();
}
});
document.addEventListener('htmx:responseError', () => {
this.setLoading(false);
this.showError('Failed to load results. Please check your connection and try again.');
});
// Handle mobile viewport changes (orientation, virtual keyboard)
this.handleMobileViewport();
},
handleResize() {
if (window.innerWidth >= 1024) {
this.showFilters = true;
}
// Auto-hide filters on mobile after interaction for better UX
// Keep current state but could add auto-hide logic here
},
handleMobileViewport() {
// Handle mobile viewport changes for better UX
if ('visualViewport' in window) {
window.visualViewport.addEventListener('resize', () => {
// Handle virtual keyboard appearance/disappearance
document.documentElement.style.setProperty(
'--viewport-height',
`${window.visualViewport.height}px`
);
});
}
},
scrollToResults() {
// Smooth scroll to results on mobile for better UX
const resultsElement = document.getElementById('park-results');
if (resultsElement) {
resultsElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
},
setLoading(loading) {
this.isLoading = loading;
// Disable form interactions while loading for better UX
const formElements = document.querySelectorAll('select, input, button');
formElements.forEach(el => {
el.disabled = loading;
});
},
showError(message) {
this.error = message;
// Auto-clear error after 5 seconds
setTimeout(() => {
this.error = null;
}, 5000);
console.error(message);
},
clearAllFilters() {
// Add loading state for better UX
this.setLoading(true);
window.location.href = '{% url "parks:park_list" %}';
},
// Utility function for better performance
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
}
</script>
{% endblock %}
<!-- AlpineJS Component Definition (HTMX + AlpineJS Only) -->
<div x-data="{
showFilters: window.innerWidth >= 1024,
viewMode: '{{ view_mode }}',
searchQuery: '{{ search_query }}',
isLoading: false,
error: null,
clearAllFilters() {
window.location.href = '{% url \"parks:park_list\" %}';
}
}"
@htmx:before-request="isLoading = true; error = null"
@htmx:after-request="isLoading = false"
@htmx:response-error="isLoading = false; error = 'Failed to load results'"
style="display: none;">
<!-- Park list functionality handled by AlpineJS + HTMX -->
</div>
{% endblock %}

View File

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

View File

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

View File

@@ -124,7 +124,81 @@
{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
<div x-data="{
tripParks: [],
showAllParks: false,
mapInitialized: false,
init() {
// Initialize map via HTMX
this.initializeMap();
},
initializeMap() {
// Use HTMX to load map component
htmx.ajax('GET', '/maps/roadtrip-map/', {
target: '#map-container',
swap: 'innerHTML'
});
this.mapInitialized = true;
},
addParkToTrip(parkId, parkName, parkLocation) {
// Check if park already exists
if (!this.tripParks.find(p => p.id === parkId)) {
this.tripParks.push({
id: parkId,
name: parkName,
location: parkLocation
});
}
},
removeParkFromTrip(parkId) {
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
},
clearTrip() {
this.tripParks = [];
},
optimizeRoute() {
if (this.tripParks.length >= 2) {
// Use HTMX to optimize route
htmx.ajax('POST', '/trips/optimize/', {
values: { parks: this.tripParks.map(p => p.id) },
target: '#trip-summary',
swap: 'innerHTML'
});
}
},
calculateRoute() {
if (this.tripParks.length >= 2) {
// Use HTMX to calculate route
htmx.ajax('POST', '/trips/calculate/', {
values: { parks: this.tripParks.map(p => p.id) },
target: '#trip-summary',
swap: 'innerHTML'
});
}
},
saveTrip() {
if (this.tripParks.length > 0) {
// Use HTMX to save trip
htmx.ajax('POST', '/trips/save/', {
values: {
name: 'Trip ' + new Date().toLocaleDateString(),
parks: this.tripParks.map(p => p.id)
},
target: '#saved-trips',
swap: 'innerHTML'
});
}
}
}" class="container px-4 mx-auto">
<!-- Header -->
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
<div>
@@ -167,7 +241,7 @@
</div>
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
<!-- Search results will be populated here -->
<!-- Search results will be populated here via HTMX -->
</div>
</div>
@@ -175,62 +249,81 @@
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
<button id="clear-trip"
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
onclick="tripPlanner.clearTrip()">
<button class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
@click="clearTrip()">
<i class="mr-1 fas fa-trash"></i>Clear All
</button>
</div>
<div id="trip-parks" class="space-y-2 min-h-20">
<div id="empty-trip" class="text-center py-8 text-gray-500 dark:text-gray-400">
<i class="fas fa-route text-3xl mb-3"></i>
<p>Add parks to start planning your trip</p>
<p class="text-sm mt-1">Search above or click parks on the map</p>
</div>
<template x-if="tripParks.length === 0">
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<i class="fas fa-route text-3xl mb-3"></i>
<p>Add parks to start planning your trip</p>
<p class="text-sm mt-1">Search above or click parks on the map</p>
</div>
</template>
<template x-for="(park, index) in tripParks" :key="park.id">
<div class="park-card">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-bold mr-3"
x-text="index + 1"></div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white" x-text="park.name"></h4>
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="park.location"></p>
</div>
</div>
<button @click="removeParkFromTrip(park.id)"
class="text-red-500 hover:text-red-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</template>
</div>
<div class="mt-4 space-y-2">
<button id="optimize-route"
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick="tripPlanner.optimizeRoute()" disabled>
<button class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="optimizeRoute()"
:disabled="tripParks.length < 2">
<i class="mr-2 fas fa-route"></i>Optimize Route
</button>
<button id="calculate-route"
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick="tripPlanner.calculateRoute()" disabled>
<button class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="calculateRoute()"
:disabled="tripParks.length < 2">
<i class="mr-2 fas fa-map"></i>Calculate Route
</button>
</div>
</div>
<!-- Trip Summary -->
<div id="trip-summary" class="trip-summary-card hidden">
<div id="trip-summary" class="trip-summary-card" x-show="tripParks.length >= 2" x-transition>
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
<div class="trip-stats">
<div class="trip-stat">
<div class="trip-stat-value" id="total-distance">-</div>
<div class="trip-stat-value">-</div>
<div class="trip-stat-label">Total Miles</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-time">-</div>
<div class="trip-stat-value">-</div>
<div class="trip-stat-label">Drive Time</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-parks">-</div>
<div class="trip-stat-value" x-text="tripParks.length">-</div>
<div class="trip-stat-label">Parks</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-rides">-</div>
<div class="trip-stat-value">-</div>
<div class="trip-stat-label">Total Rides</div>
</div>
</div>
<div class="mt-4">
<button id="save-trip"
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
onclick="tripPlanner.saveTrip()">
<button class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
@click="saveTrip()">
<i class="mr-2 fas fa-save"></i>Save Trip
</button>
</div>
@@ -243,26 +336,32 @@
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
<div class="flex gap-2">
<button id="fit-route"
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onclick="tripPlanner.fitRoute()">
<button class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
hx-post="/maps/fit-route/"
hx-vals='{"parks": "{{ tripParks|join:"," }}"}'
hx-target="#map-container"
hx-swap="none">
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
</button>
<button id="toggle-parks"
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onclick="tripPlanner.toggleAllParks()">
<i class="mr-1 fas fa-eye"></i>Show All 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"
@click="showAllParks = !showAllParks"
hx-post="/maps/toggle-parks/"
hx-vals='{"show": "{{ showAllParks }}"}'
hx-target="#map-container"
hx-swap="none">
<i class="mr-1 fas fa-eye"></i>
<span x-text="showAllParks ? 'Hide Parks' : 'Show All Parks'">Show All Parks</span>
</button>
</div>
</div>
<div id="map-container" class="map-container"></div>
<!-- Map Loading Indicator -->
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
<div class="text-center">
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
<div id="map-container" class="map-container">
<!-- Map will be loaded via HTMX -->
<div class="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-800 rounded-lg">
<div class="text-center">
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
</div>
</div>
</div>
</div>
@@ -286,7 +385,7 @@
hx-get="{% url 'parks:htmx_saved_trips' %}"
hx-trigger="load"
hx-indicator="#trips-loading">
<!-- Saved trips will be loaded here -->
<!-- Saved trips will be loaded here via HTMX -->
</div>
<div id="trips-loading" class="htmx-indicator text-center py-4">
@@ -299,524 +398,19 @@
{% endblock %}
{% block extra_js %}
<!-- Leaflet JS -->
<!-- External libraries for map functionality only -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Leaflet Routing Machine JS -->
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
<!-- Sortable JS for drag & drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
// Road Trip Planner class
class TripPlanner {
constructor() {
this.map = null;
this.tripParks = [];
this.allParks = [];
this.parkMarkers = {};
this.routeControl = null;
this.showingAllParks = false;
this.init();
}
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
<div x-data="{
init() {
this.initMap();
this.loadAllParks();
this.initDragDrop();
this.bindEvents();
}
initMap() {
// Initialize the map
this.map = L.map('map-container', {
center: [39.8283, -98.5795],
zoom: 4,
zoomControl: false
});
// Add custom zoom control
L.control.zoom({
position: 'bottomright'
}).addTo(this.map);
// Add tile layers with dark mode support
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
});
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO'
});
// Set initial tiles based on theme
if (document.documentElement.classList.contains('dark')) {
darkTiles.addTo(this.map);
} else {
lightTiles.addTo(this.map);
}
// Listen for theme changes
this.observeThemeChanges(lightTiles, darkTiles);
}
observeThemeChanges(lightTiles, darkTiles) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
if (document.documentElement.classList.contains('dark')) {
this.map.removeLayer(lightTiles);
this.map.addLayer(darkTiles);
} else {
this.map.removeLayer(darkTiles);
this.map.addLayer(lightTiles);
}
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
loadAllParks() {
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-get', '{{ map_api_urls.locations }}');
tempForm.setAttribute('hx-vals', JSON.stringify({types: 'park', limit: 1000}));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
tempForm.addEventListener('htmx:afterRequest', (event) => {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.status === 'success' && data.data.locations) {
this.allParks = data.data.locations;
}
} catch (error) {
console.error('Failed to load parks:', error);
}
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}
initDragDrop() {
// Make trip parks sortable
new Sortable(document.getElementById('trip-parks'), {
animation: 150,
ghostClass: 'drag-over',
onEnd: (evt) => {
this.reorderTripParks(evt.oldIndex, evt.newIndex);
// Only essential HTMX error handling as shown in Context7 docs
this.$el.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
console.error('HTMX Error:', evt.detail.xhr.status);
}
});
}
bindEvents() {
// Handle park search results
document.addEventListener('htmx:afterRequest', (event) => {
if (event.target.id === 'park-search-results') {
this.handleSearchResults();
}
});
}
handleSearchResults() {
const results = document.getElementById('park-search-results');
if (results.children.length > 0) {
results.classList.remove('hidden');
} else {
results.classList.add('hidden');
}
}
addParkToTrip(parkData) {
// Check if park already in trip
if (this.tripParks.find(p => p.id === parkData.id)) {
return;
}
this.tripParks.push(parkData);
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
// Hide search results
document.getElementById('park-search-results').classList.add('hidden');
document.getElementById('park-search').value = '';
}
removeParkFromTrip(parkId) {
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
}
updateTripDisplay() {
const container = document.getElementById('trip-parks');
const emptyState = document.getElementById('empty-trip');
if (this.tripParks.length === 0) {
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
// Clear existing parks (except empty state)
Array.from(container.children).forEach(child => {
if (child.id !== 'empty-trip') {
child.remove();
}
});
// Add trip parks
this.tripParks.forEach((park, index) => {
const parkElement = this.createTripParkElement(park, index);
container.appendChild(parkElement);
});
}
createTripParkElement(park, index) {
const div = document.createElement('div');
div.className = 'park-card draggable-item';
div.innerHTML = `
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
${index + 1}
</div>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
${park.name}
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
${park.formatted_location || 'Location not specified'}
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
class="text-red-500 hover:text-red-700 p-1">
<i class="fas fa-times"></i>
</button>
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
</div>
</div>
`;
return div;
}
updateTripMarkers() {
// Clear existing trip markers
Object.values(this.parkMarkers).forEach(marker => {
this.map.removeLayer(marker);
});
this.parkMarkers = {};
// Add markers for trip parks
this.tripParks.forEach((park, index) => {
const marker = this.createTripMarker(park, index);
this.parkMarkers[park.id] = marker;
marker.addTo(this.map);
});
// Fit map to show all trip parks
if (this.tripParks.length > 0) {
this.fitRoute();
}
}
createTripMarker(park, index) {
let markerClass = 'waypoint-stop';
if (index === 0) markerClass = 'waypoint-start';
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
const icon = L.divIcon({
className: `waypoint-marker ${markerClass}`,
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([park.latitude, park.longitude], { icon });
const popupContent = `
<div class="text-center">
<h3 class="font-semibold mb-2">${park.name}</h3>
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
Remove from Trip
</button>
</div>
`;
marker.bindPopup(popupContent);
return marker;
}
reorderTripParks(oldIndex, newIndex) {
const park = this.tripParks.splice(oldIndex, 1)[0];
this.tripParks.splice(newIndex, 0, park);
this.updateTripDisplay();
this.updateTripMarkers();
// Clear route to force recalculation
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
}
optimizeRoute() {
if (this.tripParks.length < 2) return;
const parkIds = this.tripParks.map(p => p.id);
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-post', '{% url "parks:htmx_optimize_route" %}');
tempForm.setAttribute('hx-vals', JSON.stringify({ park_ids: parkIds }));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = '{{ csrf_token }}';
tempForm.appendChild(csrfInput);
tempForm.addEventListener('htmx:afterRequest', (event) => {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.status === 'success' && data.optimized_order) {
// Reorder parks based on optimization
const optimizedParks = data.optimized_order.map(id =>
this.tripParks.find(p => p.id === id)
).filter(Boolean);
this.tripParks = optimizedParks;
this.updateTripDisplay();
this.updateTripMarkers();
}
} catch (error) {
console.error('Route optimization failed:', error);
}
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}
calculateRoute() {
if (this.tripParks.length < 2) return;
// Remove existing route
if (this.routeControl) {
this.map.removeControl(this.routeControl);
}
const waypoints = this.tripParks.map(park =>
L.latLng(park.latitude, park.longitude)
);
this.routeControl = L.Routing.control({
waypoints: waypoints,
routeWhileDragging: false,
addWaypoints: false,
createMarker: () => null, // Don't create default markers
lineOptions: {
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
}
}).addTo(this.map);
this.routeControl.on('routesfound', (e) => {
const route = e.routes[0];
this.updateTripSummary(route);
});
}
updateTripSummary(route) {
if (!route) return;
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
const totalTime = this.formatDuration(route.summary.totalTime);
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
document.getElementById('total-distance').textContent = totalDistance;
document.getElementById('total-time').textContent = totalTime;
document.getElementById('total-parks').textContent = this.tripParks.length;
document.getElementById('total-rides').textContent = totalRides;
document.getElementById('trip-summary').classList.remove('hidden');
}
formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
fitRoute() {
if (this.tripParks.length === 0) return;
const group = new L.featureGroup(Object.values(this.parkMarkers));
this.map.fitBounds(group.getBounds().pad(0.1));
}
toggleAllParks() {
// Implementation for showing/hiding all parks on the map
const button = document.getElementById('toggle-parks');
const icon = button.querySelector('i');
if (this.showingAllParks) {
// Hide all parks
this.showingAllParks = false;
icon.className = 'mr-1 fas fa-eye';
button.innerHTML = icon.outerHTML + 'Show All Parks';
} else {
// Show all parks
this.showingAllParks = true;
icon.className = 'mr-1 fas fa-eye-slash';
button.innerHTML = icon.outerHTML + 'Hide All Parks';
this.displayAllParks();
}
}
displayAllParks() {
// Add markers for all parks (implementation depends on requirements)
this.allParks.forEach(park => {
if (!this.parkMarkers[park.id]) {
const marker = L.marker([park.latitude, park.longitude], {
icon: L.divIcon({
className: 'location-marker location-marker-park',
html: '<div class="location-marker-inner">🎢</div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
})
});
marker.bindPopup(`
<div class="text-center">
<h3 class="font-semibold mb-2">${park.name}</h3>
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '&quot;')})"
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
Add to Trip
</button>
</div>
`);
marker.addTo(this.map);
this.parkMarkers[`all_${park.id}`] = marker;
}
});
}
updateButtons() {
const optimizeBtn = document.getElementById('optimize-route');
const calculateBtn = document.getElementById('calculate-route');
const hasEnoughParks = this.tripParks.length >= 2;
optimizeBtn.disabled = !hasEnoughParks;
calculateBtn.disabled = !hasEnoughParks;
}
clearTrip() {
this.tripParks = [];
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
document.getElementById('trip-summary').classList.add('hidden');
}
saveTrip() {
if (this.tripParks.length === 0) return;
const tripName = prompt('Enter a name for this trip:');
if (!tripName) return;
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-post', '{% url "parks:htmx_save_trip" %}');
tempForm.setAttribute('hx-vals', JSON.stringify({
name: tripName,
park_ids: this.tripParks.map(p => p.id)
}));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = '{{ csrf_token }}';
tempForm.appendChild(csrfInput);
tempForm.addEventListener('htmx:afterRequest', (event) => {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.status === 'success') {
alert('Trip saved successfully!');
// Refresh saved trips using HTMX
htmx.trigger(document.getElementById('saved-trips'), 'refresh');
} else {
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
}
} catch (error) {
console.error('Save trip failed:', error);
alert('Failed to save trip');
}
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}
}
// Global function for adding parks from search results
window.addParkToTrip = function(parkData) {
window.tripPlanner.addParkToTrip(parkData);
};
// Initialize trip planner when page loads
document.addEventListener('DOMContentLoaded', function() {
window.tripPlanner = new TripPlanner();
// Hide search results when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
document.getElementById('park-search-results').classList.add('hidden');
}
});
});
</script>
}"></div>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -149,7 +149,16 @@
<!-- Rest of the content remains unchanged -->
{% if ride.photos.exists %}
<div class="p-6 mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-6 mb-8 bg-white rounded-lg shadow dark:bg-gray-800"
x-data="{
selectedPhoto: null,
showGallery: false,
currentIndex: 0,
photos: {{ ride.photos.all|length }},
init() {
// Photo gallery initialization
}
}">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
{% include "media/partials/photo_display.html" with photos=ride.photos.all content_type="rides.ride" object_id=ride.id %}
</div>
@@ -435,7 +444,7 @@
<!-- Photo Upload Modal -->
{% if perms.media.add_photo %}
<div x-cloak
x-data="{ show: false }"
x-data="photoUploadModal"
@show-photo-upload.window="show = true"
x-show="show"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50"
@@ -454,5 +463,15 @@
{% endblock content %}
{% block extra_js %}
<script src="{% static 'js/photo-gallery.js' %}"></script>
<script>
document.addEventListener('alpine:init', () => {
// Photo Upload Modal Component
Alpine.data('photoUploadModal', () => ({
show: false,
init() {
// Modal initialization
}
}));
});
</script>
{% endblock %}

View File

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

View File

@@ -203,56 +203,20 @@
</div>
</div>
<!-- Mobile filter JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const mobileToggle = document.getElementById('mobile-filter-toggle');
const mobilePanel = document.getElementById('mobile-filter-panel');
const mobileOverlay = document.getElementById('mobile-filter-overlay');
const mobileClose = document.getElementById('mobile-filter-close');
function openMobileFilter() {
mobilePanel.classList.add('open');
mobileOverlay.classList.remove('hidden');
<!-- AlpineJS Mobile Filter Component (HTMX + AlpineJS Only) -->
<div x-data="{
mobileFilterOpen: false,
openMobileFilter() {
this.mobileFilterOpen = true;
document.body.style.overflow = 'hidden';
}
function closeMobileFilter() {
mobilePanel.classList.remove('open');
mobileOverlay.classList.add('hidden');
},
closeMobileFilter() {
this.mobileFilterOpen = false;
document.body.style.overflow = '';
}
if (mobileToggle) {
mobileToggle.addEventListener('click', openMobileFilter);
}
if (mobileClose) {
mobileClose.addEventListener('click', closeMobileFilter);
}
if (mobileOverlay) {
mobileOverlay.addEventListener('click', closeMobileFilter);
}
// Close on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && mobilePanel.classList.contains('open')) {
closeMobileFilter();
}
});
});
// Dark mode toggle (if not already implemented globally)
function toggleDarkMode() {
document.documentElement.classList.toggle('dark');
localStorage.setItem('darkMode', document.documentElement.classList.contains('dark'));
}
// Initialize dark mode from localStorage
if (localStorage.getItem('darkMode') === 'true' ||
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
}"
@keydown.escape="closeMobileFilter()"
style="display: none;">
<!-- Mobile filter functionality handled by AlpineJS -->
</div>
{% endblock %}

View File

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

View File

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

View File

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