mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 17:27:01 -05:00
Compare commits
20 Commits
757ad1be89
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a628ba9a9 | ||
|
|
679de16e4f | ||
|
|
31a2d84f9f | ||
|
|
7d04c2baa0 | ||
|
|
6575ea68c7 | ||
|
|
e1cb76f1c6 | ||
|
|
acc8308fd2 | ||
|
|
de8b6f67a3 | ||
|
|
c437ddbf28 | ||
|
|
f7b1296263 | ||
|
|
e53414d795 | ||
|
|
2328c919c9 | ||
|
|
09e2c69493 | ||
|
|
5b7b203619 | ||
|
|
47c435d2f5 | ||
|
|
ce382a4361 | ||
|
|
07ab9f28f2 | ||
|
|
40e5cf3162 | ||
|
|
b9377ead37 | ||
|
|
851709058f |
@@ -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!!!
|
||||
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/django.yml
vendored
2
.github/workflows/django.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
python-version: [3.13.1]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Homebrew on Linux
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
2
.github/workflows/review.yml
vendored
2
.github/workflows/review.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: development_environment
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -48,7 +48,6 @@
|
||||
<!-- Preload Critical Resources -->
|
||||
{% block critical_resources %}
|
||||
<link rel="preload" href="{% static 'css/tailwind.css' %}" as="style" />
|
||||
<link rel="preload" href="{% static 'js/alpine.min.js' %}?v={{ version|default:'1.0' }}" as="script" />
|
||||
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" as="style" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -62,10 +61,10 @@
|
||||
/>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js" integrity="sha384-yWakaGAFicqusuwOYEmoRjLNOC+6OFsdmwC2lbGQaRELtuVEqNzt11c2J711DeCZ" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Alpine.js (must load after components) -->
|
||||
<script defer src="{% static 'js/alpine.min.js' %}?v={{ version|default:'1.0' }}"></script>
|
||||
<script src="//unpkg.com/alpinejs" defer></script>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
|
||||
@@ -173,40 +172,60 @@
|
||||
<c-toast_container />
|
||||
|
||||
<!-- AlpineJS Global Configuration (Compliant with HTMX + AlpineJS Only Rule) -->
|
||||
<div x-data="{}" x-init="
|
||||
// Configure HTMX globally
|
||||
htmx.config.globalViewTransitions = true;
|
||||
|
||||
// Initialize Alpine stores
|
||||
Alpine.store('app', {
|
||||
user: null,
|
||||
theme: localStorage.getItem('theme') || 'system',
|
||||
searchQuery: '',
|
||||
notifications: []
|
||||
});
|
||||
|
||||
Alpine.store('toast', {
|
||||
toasts: [],
|
||||
show(message, type = 'info', duration = 5000) {
|
||||
const id = Date.now() + Math.random();
|
||||
const toast = { id, message, type, visible: true, progress: 100 };
|
||||
this.toasts.push(toast);
|
||||
if (duration > 0) {
|
||||
setTimeout(() => this.hide(id), duration);
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Configure HTMX 2.x globally with proper defaults
|
||||
htmx.config.globalViewTransitions = true;
|
||||
|
||||
// HTMX 2.x Migration: Maintain 1.x behavior for smooth scrolling
|
||||
htmx.config.scrollBehavior = 'smooth';
|
||||
|
||||
// HTMX 2.x Migration: Keep DELETE requests using form-encoded body (like 1.x)
|
||||
htmx.config.methodsThatUseUrlParams = ["get"];
|
||||
|
||||
// HTMX 2.x Migration: Allow cross-domain requests (like 1.x)
|
||||
htmx.config.selfRequestsOnly = false;
|
||||
|
||||
// Enhanced HTMX event handling for better UX
|
||||
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||
// Add CSRF token to all HTMX requests
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||
if (csrfToken) {
|
||||
evt.detail.headers['X-CSRFToken'] = csrfToken.getAttribute('content');
|
||||
}
|
||||
return id;
|
||||
},
|
||||
hide(id) {
|
||||
const toast = this.toasts.find(t => t.id === id);
|
||||
if (toast) {
|
||||
toast.visible = false;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Initialize Alpine stores
|
||||
Alpine.store('app', {
|
||||
user: null,
|
||||
theme: localStorage.getItem('theme') || 'system',
|
||||
searchQuery: '',
|
||||
notifications: []
|
||||
});
|
||||
|
||||
Alpine.store('toast', {
|
||||
toasts: [],
|
||||
show(message, type = 'info', duration = 5000) {
|
||||
const id = Date.now() + Math.random();
|
||||
const toast = { id, message, type, visible: true, progress: 100 };
|
||||
this.toasts.push(toast);
|
||||
if (duration > 0) {
|
||||
setTimeout(() => this.hide(id), duration);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
hide(id) {
|
||||
const toast = this.toasts.find(t => t.id === id);
|
||||
if (toast) {
|
||||
toast.visible = false;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
" style="display: none;"></div>
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-thrill-primary to-purple-500 transition-all duration-300 group-hover:w-full"></span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'rides:list' %}"
|
||||
<a href="{% url 'rides:global_ride_list' %}"
|
||||
class="nav-link group relative"
|
||||
hx-get="{% url 'rides:list' %}"
|
||||
hx-get="{% url 'rides:global_ride_list' %}"
|
||||
hx-target="#main-content"
|
||||
hx-swap="innerHTML transition:true">
|
||||
<i class="fas fa-rocket mr-2 text-thrill-secondary"></i>
|
||||
@@ -367,7 +367,7 @@
|
||||
<span class="font-medium">Parks</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'rides:ride_list' %}"
|
||||
<a href="{% url 'rides:global_ride_list' %}"
|
||||
class="flex items-center p-3 rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-700/50 transition-colors"
|
||||
@click="isOpen = false">
|
||||
<i class="fas fa-rocket mr-3 text-thrill-secondary"></i>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container max-w-6xl px-4 py-6 mx-auto">
|
||||
<div class="container max-w-6xl px-4 py-6 mx-auto" x-data="moderationDashboard()" @retry-load="retryLoad()">
|
||||
<div id="dashboard-content" class="relative transition-all duration-200">
|
||||
{% block moderation_content %}
|
||||
{% include "moderation/partials/dashboard_content.html" %}
|
||||
@@ -169,7 +169,7 @@
|
||||
There was a problem loading the content. Please try again.
|
||||
</p>
|
||||
<button class="px-4 py-2 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600"
|
||||
onclick="window.location.reload()">
|
||||
@click="$dispatch('retry-load')">
|
||||
<i class="mr-2 fas fa-sync-alt"></i>
|
||||
Retry
|
||||
</button>
|
||||
@@ -180,133 +180,156 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- AlpineJS Moderation Dashboard Component (HTMX + AlpineJS Only) -->
|
||||
<div x-data="{
|
||||
showLoading: false,
|
||||
errorMessage: null,
|
||||
showError(message) {
|
||||
this.errorMessage = message;
|
||||
}
|
||||
}"
|
||||
@htmx:before-request="showLoading = true"
|
||||
@htmx:after-request="showLoading = false"
|
||||
@htmx:response-error="showError('Failed to load content')"
|
||||
style="display: none;">
|
||||
<!-- Dashboard functionality handled by AlpineJS + HTMX -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// HTMX Configuration
|
||||
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
|
||||
});
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Moderation Dashboard Component
|
||||
Alpine.data('moderationDashboard', () => ({
|
||||
showLoading: false,
|
||||
errorMessage: null,
|
||||
|
||||
init() {
|
||||
// HTMX Configuration
|
||||
this.setupHTMXConfig();
|
||||
this.setupEventListeners();
|
||||
this.setupSearchDebouncing();
|
||||
this.setupInfiniteScroll();
|
||||
this.setupKeyboardNavigation();
|
||||
},
|
||||
|
||||
setupHTMXConfig() {
|
||||
document.body.addEventListener('htmx:configRequest', (evt) => {
|
||||
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
|
||||
});
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
// Enhanced HTMX Event Handlers
|
||||
document.body.addEventListener('htmx:beforeRequest', (evt) => {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
this.showLoadingState();
|
||||
}
|
||||
});
|
||||
|
||||
// Loading and Error State Management
|
||||
const dashboard = {
|
||||
content: document.getElementById('dashboard-content'),
|
||||
skeleton: document.getElementById('loading-skeleton'),
|
||||
errorState: document.getElementById('error-state'),
|
||||
errorMessage: document.getElementById('error-message'),
|
||||
document.body.addEventListener('htmx:afterOnLoad', (evt) => {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
this.hideLoadingState();
|
||||
this.resetFocus(evt.detail.target);
|
||||
}
|
||||
});
|
||||
|
||||
showLoading() {
|
||||
this.content.setAttribute('aria-busy', 'true');
|
||||
this.content.style.opacity = '0';
|
||||
this.errorState.classList.add('hidden');
|
||||
},
|
||||
|
||||
hideLoading() {
|
||||
this.content.setAttribute('aria-busy', 'false');
|
||||
this.content.style.opacity = '1';
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.errorState.classList.remove('hidden');
|
||||
this.errorMessage.textContent = message || 'There was a problem loading the content. Please try again.';
|
||||
// Announce error to screen readers
|
||||
this.errorMessage.setAttribute('role', 'alert');
|
||||
}
|
||||
};
|
||||
|
||||
// Enhanced HTMX Event Handlers
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
dashboard.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
dashboard.hideLoading();
|
||||
// Reset focus for accessibility
|
||||
const firstFocusable = evt.detail.target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
dashboard.showError(evt.detail.error);
|
||||
}
|
||||
});
|
||||
|
||||
// Search Input Debouncing
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Apply debouncing to search inputs
|
||||
document.querySelectorAll('[data-search]').forEach(input => {
|
||||
const originalSearch = () => {
|
||||
htmx.trigger(input, 'input');
|
||||
};
|
||||
const debouncedSearch = debounce(originalSearch, 300);
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
e.preventDefault();
|
||||
debouncedSearch();
|
||||
});
|
||||
});
|
||||
|
||||
// Virtual Scrolling for Large Lists
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1
|
||||
};
|
||||
|
||||
const loadMoreContent = (entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !entry.target.classList.contains('loading')) {
|
||||
entry.target.classList.add('loading');
|
||||
htmx.trigger(entry.target, 'intersect');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(loadMoreContent, observerOptions);
|
||||
document.querySelectorAll('[data-infinite-scroll]').forEach(el => observer.observe(el));
|
||||
|
||||
// Keyboard Navigation Enhancement
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const openModals = document.querySelectorAll('[x-show="showNotes"]');
|
||||
openModals.forEach(modal => {
|
||||
const alpineData = modal.__x.$data;
|
||||
if (alpineData.showNotes) {
|
||||
alpineData.showNotes = false;
|
||||
document.body.addEventListener('htmx:responseError', (evt) => {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
this.showErrorState(evt.detail.error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
showLoadingState() {
|
||||
const content = this.$el.querySelector('#dashboard-content');
|
||||
if (content) {
|
||||
content.setAttribute('aria-busy', 'true');
|
||||
content.style.opacity = '0';
|
||||
}
|
||||
});
|
||||
}
|
||||
const errorState = this.$el.querySelector('#error-state');
|
||||
if (errorState) {
|
||||
errorState.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
hideLoadingState() {
|
||||
const content = this.$el.querySelector('#dashboard-content');
|
||||
if (content) {
|
||||
content.setAttribute('aria-busy', 'false');
|
||||
content.style.opacity = '1';
|
||||
}
|
||||
},
|
||||
|
||||
showErrorState(message) {
|
||||
const errorState = this.$el.querySelector('#error-state');
|
||||
const errorMessage = this.$el.querySelector('#error-message');
|
||||
|
||||
if (errorState) {
|
||||
errorState.classList.remove('hidden');
|
||||
}
|
||||
if (errorMessage) {
|
||||
errorMessage.textContent = message || 'There was a problem loading the content. Please try again.';
|
||||
errorMessage.setAttribute('role', 'alert');
|
||||
}
|
||||
},
|
||||
|
||||
resetFocus(target) {
|
||||
const firstFocusable = target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
}
|
||||
},
|
||||
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
setupSearchDebouncing() {
|
||||
const searchInputs = this.$el.querySelectorAll('[data-search]');
|
||||
searchInputs.forEach(input => {
|
||||
const originalSearch = () => {
|
||||
htmx.trigger(input, 'input');
|
||||
};
|
||||
const debouncedSearch = this.debounce(originalSearch, 300);
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
e.preventDefault();
|
||||
debouncedSearch();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setupInfiniteScroll() {
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1
|
||||
};
|
||||
|
||||
const loadMoreContent = (entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !entry.target.classList.contains('loading')) {
|
||||
entry.target.classList.add('loading');
|
||||
htmx.trigger(entry.target, 'intersect');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(loadMoreContent, observerOptions);
|
||||
const infiniteScrollElements = this.$el.querySelectorAll('[data-infinite-scroll]');
|
||||
infiniteScrollElements.forEach(el => observer.observe(el));
|
||||
},
|
||||
|
||||
setupKeyboardNavigation() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const openModals = this.$el.querySelectorAll('[x-show="showNotes"]');
|
||||
openModals.forEach(modal => {
|
||||
const alpineData = modal.__x?.$data;
|
||||
if (alpineData && alpineData.showNotes) {
|
||||
alpineData.showNotes = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
retryLoad() {
|
||||
window.location.reload();
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -124,7 +124,81 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
|
||||
<div x-data="{
|
||||
tripParks: [],
|
||||
showAllParks: false,
|
||||
mapInitialized: false,
|
||||
|
||||
init() {
|
||||
// Initialize map via HTMX
|
||||
this.initializeMap();
|
||||
},
|
||||
|
||||
initializeMap() {
|
||||
// Use HTMX to load map component
|
||||
htmx.ajax('GET', '/maps/roadtrip-map/', {
|
||||
target: '#map-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
this.mapInitialized = true;
|
||||
},
|
||||
|
||||
addParkToTrip(parkId, parkName, parkLocation) {
|
||||
// Check if park already exists
|
||||
if (!this.tripParks.find(p => p.id === parkId)) {
|
||||
this.tripParks.push({
|
||||
id: parkId,
|
||||
name: parkName,
|
||||
location: parkLocation
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
removeParkFromTrip(parkId) {
|
||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
||||
},
|
||||
|
||||
clearTrip() {
|
||||
this.tripParks = [];
|
||||
},
|
||||
|
||||
optimizeRoute() {
|
||||
if (this.tripParks.length >= 2) {
|
||||
// Use HTMX to optimize route
|
||||
htmx.ajax('POST', '/trips/optimize/', {
|
||||
values: { parks: this.tripParks.map(p => p.id) },
|
||||
target: '#trip-summary',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
calculateRoute() {
|
||||
if (this.tripParks.length >= 2) {
|
||||
// Use HTMX to calculate route
|
||||
htmx.ajax('POST', '/trips/calculate/', {
|
||||
values: { parks: this.tripParks.map(p => p.id) },
|
||||
target: '#trip-summary',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
saveTrip() {
|
||||
if (this.tripParks.length > 0) {
|
||||
// Use HTMX to save trip
|
||||
htmx.ajax('POST', '/trips/save/', {
|
||||
values: {
|
||||
name: 'Trip ' + new Date().toLocaleDateString(),
|
||||
parks: this.tripParks.map(p => p.id)
|
||||
},
|
||||
target: '#saved-trips',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
}
|
||||
}" class="container px-4 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
@@ -167,7 +241,7 @@
|
||||
</div>
|
||||
|
||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
|
||||
<!-- Search results will be populated here -->
|
||||
<!-- Search results will be populated here via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,61 +249,80 @@
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
||||
<button id="clear-trip"
|
||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
<button class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
@click="clearTrip()">
|
||||
<i class="mr-1 fas fa-trash"></i>Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="trip-parks" class="space-y-2 min-h-20">
|
||||
<div id="empty-trip" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-route text-3xl mb-3"></i>
|
||||
<p>Add parks to start planning your trip</p>
|
||||
<p class="text-sm mt-1">Search above or click parks on the map</p>
|
||||
</div>
|
||||
<template x-if="tripParks.length === 0">
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-route text-3xl mb-3"></i>
|
||||
<p>Add parks to start planning your trip</p>
|
||||
<p class="text-sm mt-1">Search above or click parks on the map</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-for="(park, index) in tripParks" :key="park.id">
|
||||
<div class="park-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-bold mr-3"
|
||||
x-text="index + 1"></div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white" x-text="park.name"></h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="park.location"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="removeParkFromTrip(park.id)"
|
||||
class="text-red-500 hover:text-red-700">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<button id="optimize-route"
|
||||
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="optimizeRoute()" :disabled="tripParks.length < 2">
|
||||
<button class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="optimizeRoute()"
|
||||
:disabled="tripParks.length < 2">
|
||||
<i class="mr-2 fas fa-route"></i>Optimize Route
|
||||
</button>
|
||||
<button id="calculate-route"
|
||||
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="calculateRoute()" :disabled="tripParks.length < 2">
|
||||
<button class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="calculateRoute()"
|
||||
:disabled="tripParks.length < 2">
|
||||
<i class="mr-2 fas fa-map"></i>Calculate Route
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trip Summary -->
|
||||
<div id="trip-summary" class="trip-summary-card hidden">
|
||||
<div id="trip-summary" class="trip-summary-card" x-show="tripParks.length >= 2" x-transition>
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
|
||||
|
||||
<div class="trip-stats">
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-distance">-</div>
|
||||
<div class="trip-stat-value">-</div>
|
||||
<div class="trip-stat-label">Total Miles</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-time">-</div>
|
||||
<div class="trip-stat-value">-</div>
|
||||
<div class="trip-stat-label">Drive Time</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-parks">-</div>
|
||||
<div class="trip-stat-value" x-text="tripParks.length">-</div>
|
||||
<div class="trip-stat-label">Parks</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-rides">-</div>
|
||||
<div class="trip-stat-value">-</div>
|
||||
<div class="trip-stat-label">Total Rides</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button id="save-trip"
|
||||
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
<button class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
@click="saveTrip()">
|
||||
<i class="mr-2 fas fa-save"></i>Save Trip
|
||||
</button>
|
||||
@@ -243,26 +336,32 @@
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
|
||||
<div class="flex gap-2">
|
||||
<button id="fit-route"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="fitRoute()">
|
||||
<button class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
hx-post="/maps/fit-route/"
|
||||
hx-vals='{"parks": "{{ tripParks|join:"," }}"}'
|
||||
hx-target="#map-container"
|
||||
hx-swap="none">
|
||||
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
||||
</button>
|
||||
<button id="toggle-parks"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="toggleAllParks()">
|
||||
<i class="mr-1 fas fa-eye"></i><span x-text="showAllParks ? 'Hide Parks' : 'Show All Parks'">Show All Parks</span>
|
||||
<button class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="showAllParks = !showAllParks"
|
||||
hx-post="/maps/toggle-parks/"
|
||||
hx-vals='{"show": "{{ showAllParks }}"}'
|
||||
hx-target="#map-container"
|
||||
hx-swap="none">
|
||||
<i class="mr-1 fas fa-eye"></i>
|
||||
<span x-text="showAllParks ? 'Hide Parks' : 'Show All Parks'">Show All Parks</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-container" class="map-container"></div>
|
||||
|
||||
<!-- Map Loading Indicator -->
|
||||
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
|
||||
<div id="map-container" class="map-container">
|
||||
<!-- Map will be loaded via HTMX -->
|
||||
<div class="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,7 +385,7 @@
|
||||
hx-get="{% url 'parks:htmx_saved_trips' %}"
|
||||
hx-trigger="load"
|
||||
hx-indicator="#trips-loading">
|
||||
<!-- Saved trips will be loaded here -->
|
||||
<!-- Saved trips will be loaded here via HTMX -->
|
||||
</div>
|
||||
|
||||
<div id="trips-loading" class="htmx-indicator text-center py-4">
|
||||
@@ -299,255 +398,19 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Leaflet JS -->
|
||||
<!-- External libraries for map functionality only -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<!-- Leaflet Routing Machine JS -->
|
||||
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
|
||||
<!-- Sortable JS for drag & drop -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('tripPlanner', () => ({
|
||||
map: null,
|
||||
tripParks: [],
|
||||
allParks: [],
|
||||
showAllParks: false,
|
||||
routeControl: null,
|
||||
parkMarkers: {},
|
||||
|
||||
init() {
|
||||
this.initMap();
|
||||
this.setupSortable();
|
||||
},
|
||||
|
||||
initMap() {
|
||||
if (typeof L !== 'undefined') {
|
||||
this.map = L.map('map-container').setView([39.8283, -98.5795], 4);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(this.map);
|
||||
|
||||
this.loadAllParks();
|
||||
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
|
||||
<div x-data="{
|
||||
init() {
|
||||
// Only essential HTMX error handling as shown in Context7 docs
|
||||
this.$el.addEventListener('htmx:responseError', (evt) => {
|
||||
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
|
||||
console.error('HTMX Error:', evt.detail.xhr.status);
|
||||
}
|
||||
},
|
||||
|
||||
setupSortable() {
|
||||
if (typeof Sortable !== 'undefined') {
|
||||
const tripParksEl = document.getElementById('trip-parks');
|
||||
if (tripParksEl) {
|
||||
Sortable.create(tripParksEl, {
|
||||
animation: 150,
|
||||
ghostClass: 'drag-over',
|
||||
onEnd: (evt) => {
|
||||
this.reorderTrip(evt.oldIndex, evt.newIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
loadAllParks() {
|
||||
// Load parks via HTMX or fetch
|
||||
fetch('/api/parks/')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
this.allParks = data;
|
||||
this.displayAllParks();
|
||||
})
|
||||
.catch(error => console.error('Error loading parks:', error));
|
||||
},
|
||||
|
||||
displayAllParks() {
|
||||
this.allParks.forEach(park => {
|
||||
if (park.latitude && park.longitude) {
|
||||
const marker = L.marker([park.latitude, park.longitude])
|
||||
.bindPopup(`
|
||||
<div class="p-2">
|
||||
<h3 class="font-semibold">${park.name}</h3>
|
||||
<p class="text-sm text-gray-600">${park.location || ''}</p>
|
||||
<button onclick="Alpine.store('tripPlanner').addParkToTrip(${park.id})"
|
||||
class="mt-2 px-2 py-1 bg-blue-500 text-white text-xs rounded">
|
||||
Add to Trip
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
this.parkMarkers[park.id] = marker;
|
||||
if (this.showAllParks) {
|
||||
marker.addTo(this.map);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
addParkToTrip(parkId) {
|
||||
const park = this.allParks.find(p => p.id === parkId);
|
||||
if (park && !this.tripParks.find(p => p.id === parkId)) {
|
||||
this.tripParks.push(park);
|
||||
this.updateTripDisplay();
|
||||
this.updateButtons();
|
||||
}
|
||||
},
|
||||
|
||||
removeParkFromTrip(parkId) {
|
||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
||||
this.updateTripDisplay();
|
||||
this.updateButtons();
|
||||
this.clearRoute();
|
||||
},
|
||||
|
||||
clearTrip() {
|
||||
this.tripParks = [];
|
||||
this.updateTripDisplay();
|
||||
this.updateButtons();
|
||||
this.clearRoute();
|
||||
},
|
||||
|
||||
updateTripDisplay() {
|
||||
const container = document.getElementById('trip-parks');
|
||||
const emptyState = document.getElementById('empty-trip');
|
||||
|
||||
if (this.tripParks.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
container.innerHTML = emptyState.outerHTML;
|
||||
} else {
|
||||
container.innerHTML = this.tripParks.map((park, index) => `
|
||||
<div class="park-card draggable-item" data-park-id="${park.id}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-bold mr-3">
|
||||
${index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">${park.name}</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">${park.location || ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="removeParkFromTrip(${park.id})"
|
||||
class="text-red-500 hover:text-red-700">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
},
|
||||
|
||||
updateButtons() {
|
||||
const hasParks = this.tripParks.length >= 2;
|
||||
document.getElementById('optimize-route').disabled = !hasParks;
|
||||
document.getElementById('calculate-route').disabled = !hasParks;
|
||||
},
|
||||
|
||||
optimizeRoute() {
|
||||
// Implement route optimization logic
|
||||
console.log('Optimizing route for', this.tripParks.length, 'parks');
|
||||
},
|
||||
|
||||
calculateRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
this.clearRoute();
|
||||
|
||||
const waypoints = this.tripParks.map(park =>
|
||||
L.latLng(park.latitude, park.longitude)
|
||||
);
|
||||
|
||||
if (typeof L.Routing !== 'undefined') {
|
||||
this.routeControl = L.Routing.control({
|
||||
waypoints: waypoints,
|
||||
routeWhileDragging: false,
|
||||
addWaypoints: false,
|
||||
createMarker: (i, waypoint, n) => {
|
||||
const park = this.tripParks[i];
|
||||
return L.marker(waypoint.latLng, {
|
||||
icon: L.divIcon({
|
||||
html: `<div class="waypoint-marker-inner">${i + 1}</div>`,
|
||||
className: `waypoint-marker ${i === 0 ? 'waypoint-start' : i === n - 1 ? 'waypoint-end' : 'waypoint-stop'}`,
|
||||
iconSize: [30, 30]
|
||||
})
|
||||
}).bindPopup(park.name);
|
||||
}
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeControl.on('routesfound', (e) => {
|
||||
const route = e.routes[0];
|
||||
this.updateTripSummary(route);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearRoute() {
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
this.hideTripSummary();
|
||||
},
|
||||
|
||||
updateTripSummary(route) {
|
||||
const summary = route.summary;
|
||||
document.getElementById('total-distance').textContent = Math.round(summary.totalDistance / 1609.34); // Convert to miles
|
||||
document.getElementById('total-time').textContent = Math.round(summary.totalTime / 3600) + 'h';
|
||||
document.getElementById('total-parks').textContent = this.tripParks.length;
|
||||
document.getElementById('total-rides').textContent = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
||||
|
||||
document.getElementById('trip-summary').classList.remove('hidden');
|
||||
},
|
||||
|
||||
hideTripSummary() {
|
||||
document.getElementById('trip-summary').classList.add('hidden');
|
||||
},
|
||||
|
||||
fitRoute() {
|
||||
if (this.tripParks.length > 0) {
|
||||
const group = new L.featureGroup(
|
||||
this.tripParks.map(park => L.marker([park.latitude, park.longitude]))
|
||||
);
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
},
|
||||
|
||||
toggleAllParks() {
|
||||
this.showAllParks = !this.showAllParks;
|
||||
|
||||
Object.values(this.parkMarkers).forEach(marker => {
|
||||
if (this.showAllParks) {
|
||||
marker.addTo(this.map);
|
||||
} else {
|
||||
this.map.removeLayer(marker);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
reorderTrip(oldIndex, newIndex) {
|
||||
const park = this.tripParks.splice(oldIndex, 1)[0];
|
||||
this.tripParks.splice(newIndex, 0, park);
|
||||
this.updateTripDisplay();
|
||||
this.clearRoute();
|
||||
},
|
||||
|
||||
saveTrip() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const tripData = {
|
||||
name: `Trip ${new Date().toLocaleDateString()}`,
|
||||
parks: this.tripParks.map(p => p.id)
|
||||
};
|
||||
|
||||
// Save via HTMX
|
||||
htmx.ajax('POST', '/trips/save/', {
|
||||
values: tripData,
|
||||
target: '#saved-trips',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Trip Planner Component Container -->
|
||||
<div x-data="tripPlanner" x-init="init()" style="display: none;"></div>
|
||||
});
|
||||
}
|
||||
}"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -6,8 +6,22 @@
|
||||
{% block meta_description %}Find your perfect theme park adventure with our advanced search. Filter by location, thrill level, ride type, and more to discover exactly what you're looking for.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Advanced Search Page -->
|
||||
<div class="min-h-screen bg-gradient-to-br from-white via-blue-50/30 to-indigo-50/30 dark:from-gray-950 dark:via-indigo-950/30 dark:to-purple-950/30">
|
||||
<!-- Advanced Search Page - HTMX + AlpineJS ONLY -->
|
||||
<div class="min-h-screen bg-gradient-to-br from-white via-blue-50/30 to-indigo-50/30 dark:from-gray-950 dark:via-indigo-950/30 dark:to-purple-950/30"
|
||||
x-data="{
|
||||
searchType: 'parks',
|
||||
viewMode: 'grid',
|
||||
|
||||
toggleSearchType(type) {
|
||||
this.searchType = type;
|
||||
// Use HTMX to update filters
|
||||
htmx.trigger('#filter-form', 'change');
|
||||
},
|
||||
|
||||
setViewMode(mode) {
|
||||
this.viewMode = mode;
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Search Header -->
|
||||
<section class="py-16 bg-gradient-to-r from-thrill-primary/10 via-purple-500/10 to-pink-500/10 backdrop-blur-sm">
|
||||
@@ -25,12 +39,12 @@
|
||||
<!-- Quick Search Bar -->
|
||||
<div class="relative max-w-2xl mx-auto">
|
||||
<input type="text"
|
||||
id="quick-search"
|
||||
placeholder="Quick search: parks, rides, locations..."
|
||||
class="w-full pl-16 pr-6 py-4 bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm border border-neutral-300/50 dark:border-neutral-600/50 rounded-2xl text-lg shadow-lg focus:shadow-xl focus:bg-white dark:focus:bg-neutral-800 focus:border-thrill-primary focus:ring-2 focus:ring-thrill-primary/20 transition-all duration-300"
|
||||
hx-get="/search/quick/"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#quick-results">
|
||||
hx-target="#quick-results"
|
||||
hx-swap="innerHTML">
|
||||
<div class="absolute left-6 top-1/2 transform -translate-y-1/2">
|
||||
<i class="fas fa-search text-2xl text-thrill-primary"></i>
|
||||
</div>
|
||||
@@ -55,7 +69,7 @@
|
||||
Filters
|
||||
</h2>
|
||||
|
||||
<form id="advanced-search-form"
|
||||
<form id="filter-form"
|
||||
hx-get="/search/results/"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="change, submit"
|
||||
@@ -66,18 +80,30 @@
|
||||
<div class="form-group">
|
||||
<label class="form-label">Search For</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-thrill-primary/5 transition-colors">
|
||||
<input type="radio" name="search_type" value="parks" checked class="sr-only">
|
||||
<label class="flex items-center p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-thrill-primary/5 transition-colors"
|
||||
:class="{ 'bg-thrill-primary/10 border-thrill-primary': searchType === 'parks' }">
|
||||
<input type="radio"
|
||||
name="search_type"
|
||||
value="parks"
|
||||
x-model="searchType"
|
||||
class="sr-only">
|
||||
<div class="w-4 h-4 border-2 border-thrill-primary rounded-full mr-3 flex items-center justify-center">
|
||||
<div class="w-2 h-2 bg-thrill-primary rounded-full opacity-0 transition-opacity"></div>
|
||||
<div class="w-2 h-2 bg-thrill-primary rounded-full transition-opacity"
|
||||
:class="{ 'opacity-100': searchType === 'parks', 'opacity-0': searchType !== 'parks' }"></div>
|
||||
</div>
|
||||
<i class="fas fa-map-marked-alt mr-2 text-thrill-primary"></i>
|
||||
Parks
|
||||
</label>
|
||||
<label class="flex items-center p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-thrill-secondary/5 transition-colors">
|
||||
<input type="radio" name="search_type" value="rides" class="sr-only">
|
||||
<label class="flex items-center p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-thrill-secondary/5 transition-colors"
|
||||
:class="{ 'bg-thrill-secondary/10 border-thrill-secondary': searchType === 'rides' }">
|
||||
<input type="radio"
|
||||
name="search_type"
|
||||
value="rides"
|
||||
x-model="searchType"
|
||||
class="sr-only">
|
||||
<div class="w-4 h-4 border-2 border-thrill-secondary rounded-full mr-3 flex items-center justify-center">
|
||||
<div class="w-2 h-2 bg-thrill-secondary rounded-full opacity-0 transition-opacity"></div>
|
||||
<div class="w-2 h-2 bg-thrill-secondary rounded-full transition-opacity"
|
||||
:class="{ 'opacity-100': searchType === 'rides', 'opacity-0': searchType !== 'rides' }"></div>
|
||||
</div>
|
||||
<i class="fas fa-rocket mr-2 text-thrill-secondary"></i>
|
||||
Rides
|
||||
@@ -109,7 +135,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Park-Specific Filters -->
|
||||
<div id="park-filters" class="space-y-6">
|
||||
<div x-show="searchType === 'parks'" x-transition class="space-y-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Park Type</label>
|
||||
<select name="park_type" class="form-select">
|
||||
@@ -125,62 +151,56 @@
|
||||
<label class="form-label">Park Status</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="status" value="OPERATING" checked class="sr-only">
|
||||
<div class="checkbox-custom mr-3"></div>
|
||||
<span class="badge-operating">Operating</span>
|
||||
<input type="checkbox" name="status" value="OPERATING" checked class="form-checkbox">
|
||||
<span class="badge-operating ml-2">Operating</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="status" value="CONSTRUCTION" class="sr-only">
|
||||
<div class="checkbox-custom mr-3"></div>
|
||||
<span class="badge-construction">Under Construction</span>
|
||||
<input type="checkbox" name="status" value="CONSTRUCTION" class="form-checkbox">
|
||||
<span class="badge-construction ml-2">Under Construction</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Minimum Rides</label>
|
||||
<input type="range" name="min_rides" min="0" max="100" value="0" class="w-full">
|
||||
<input type="range" name="min_rides" min="0" max="100" value="0" class="w-full form-range">
|
||||
<div class="flex justify-between text-sm text-neutral-500 mt-1">
|
||||
<span>0</span>
|
||||
<span id="min-rides-value">0</span>
|
||||
<span x-text="$el.querySelector('input[name=min_rides]')?.value || '0'"></span>
|
||||
<span>100+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride-Specific Filters -->
|
||||
<div id="ride-filters" class="space-y-6 hidden">
|
||||
<div x-show="searchType === 'rides'" x-transition class="space-y-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Thrill Level</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="thrill_level" value="MILD" class="sr-only">
|
||||
<div class="checkbox-custom mr-3"></div>
|
||||
<span class="badge bg-green-500/10 text-green-600 border-green-500/20">
|
||||
<input type="checkbox" name="thrill_level" value="MILD" class="form-checkbox">
|
||||
<span class="badge bg-green-500/10 text-green-600 border-green-500/20 ml-2">
|
||||
<i class="fas fa-leaf mr-1"></i>
|
||||
Family Friendly
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="thrill_level" value="MODERATE" class="sr-only">
|
||||
<div class="checkbox-custom mr-3"></div>
|
||||
<span class="badge bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
|
||||
<input type="checkbox" name="thrill_level" value="MODERATE" class="form-checkbox">
|
||||
<span class="badge bg-yellow-500/10 text-yellow-600 border-yellow-500/20 ml-2">
|
||||
<i class="fas fa-star mr-1"></i>
|
||||
Moderate
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="thrill_level" value="HIGH" class="sr-only">
|
||||
<div class="checkbox-custom mr-3"></div>
|
||||
<span class="badge bg-orange-500/10 text-orange-600 border-orange-500/20">
|
||||
<input type="checkbox" name="thrill_level" value="HIGH" class="form-checkbox">
|
||||
<span class="badge bg-orange-500/10 text-orange-600 border-orange-500/20 ml-2">
|
||||
<i class="fas fa-bolt mr-1"></i>
|
||||
High Thrill
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="thrill_level" value="EXTREME" class="sr-only">
|
||||
<div class="checkbox-custom mr-3"></div>
|
||||
<span class="badge bg-red-500/10 text-red-600 border-red-500/20">
|
||||
<input type="checkbox" name="thrill_level" value="EXTREME" class="form-checkbox">
|
||||
<span class="badge bg-red-500/10 text-red-600 border-red-500/20 ml-2">
|
||||
<i class="fas fa-fire mr-1"></i>
|
||||
Extreme
|
||||
</span>
|
||||
@@ -202,20 +222,20 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Minimum Height (ft)</label>
|
||||
<input type="range" name="min_height" min="0" max="500" value="0" class="w-full">
|
||||
<input type="range" name="min_height" min="0" max="500" value="0" class="w-full form-range">
|
||||
<div class="flex justify-between text-sm text-neutral-500 mt-1">
|
||||
<span>0ft</span>
|
||||
<span id="min-height-value">0ft</span>
|
||||
<span x-text="($el.querySelector('input[name=min_height]')?.value || '0') + 'ft'"></span>
|
||||
<span>500ft+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Minimum Speed (mph)</label>
|
||||
<input type="range" name="min_speed" min="0" max="150" value="0" class="w-full">
|
||||
<input type="range" name="min_speed" min="0" max="150" value="0" class="w-full form-range">
|
||||
<div class="flex justify-between text-sm text-neutral-500 mt-1">
|
||||
<span>0mph</span>
|
||||
<span id="min-speed-value">0mph</span>
|
||||
<span x-text="($el.querySelector('input[name=min_speed]')?.value || '0') + 'mph'"></span>
|
||||
<span>150mph+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,9 +256,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<button type="button"
|
||||
id="clear-filters"
|
||||
class="btn-ghost w-full">
|
||||
<button type="reset"
|
||||
class="btn-ghost w-full"
|
||||
hx-get="/search/results/"
|
||||
hx-target="#search-results"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear All Filters
|
||||
</button>
|
||||
@@ -252,24 +274,30 @@
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">Search Results</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400" id="results-count">
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
Use filters to find your perfect adventure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center space-x-2 bg-white dark:bg-neutral-800 rounded-lg p-1 border border-neutral-200 dark:border-neutral-700">
|
||||
<button class="p-2 rounded-md bg-thrill-primary text-white" id="grid-view">
|
||||
<button class="p-2 rounded-md transition-colors"
|
||||
:class="{ 'bg-thrill-primary text-white': viewMode === 'grid', 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-700': viewMode !== 'grid' }"
|
||||
@click="setViewMode('grid')">
|
||||
<i class="fas fa-th-large"></i>
|
||||
</button>
|
||||
<button class="p-2 rounded-md text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-700" id="list-view">
|
||||
<button class="p-2 rounded-md transition-colors"
|
||||
:class="{ 'bg-thrill-primary text-white': viewMode === 'list', 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-700': viewMode !== 'list' }"
|
||||
@click="setViewMode('list')">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results Container -->
|
||||
<div id="search-results" class="min-h-96">
|
||||
<div id="search-results"
|
||||
class="min-h-96"
|
||||
:class="{ 'grid-view': viewMode === 'grid', 'list-view': viewMode === 'list' }">
|
||||
<!-- Initial State -->
|
||||
<div class="text-center py-16">
|
||||
<div class="w-24 h-24 bg-gradient-to-r from-thrill-primary to-purple-500 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
@@ -283,7 +311,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Load More Button -->
|
||||
<div id="load-more-container" class="text-center mt-8 hidden">
|
||||
<div class="text-center mt-8 hidden" id="load-more-container">
|
||||
<button class="btn-secondary btn-lg"
|
||||
hx-get="/search/results/"
|
||||
hx-target="#search-results"
|
||||
@@ -298,51 +326,8 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- AlpineJS Advanced Search Component (HTMX + AlpineJS Only) -->
|
||||
<div x-data="{
|
||||
searchType: 'parks',
|
||||
viewMode: 'grid',
|
||||
toggleSearchType(type) {
|
||||
this.searchType = type;
|
||||
},
|
||||
clearFilters() {
|
||||
document.getElementById('advanced-search-form').reset();
|
||||
this.searchType = 'parks';
|
||||
},
|
||||
setViewMode(mode) {
|
||||
this.viewMode = mode;
|
||||
}
|
||||
}" style="display: none;">
|
||||
<!-- Advanced search functionality handled by AlpineJS + HTMX -->
|
||||
</div>
|
||||
|
||||
<!-- Custom CSS for checkboxes and enhanced styling -->
|
||||
<!-- Custom CSS for enhanced styling -->
|
||||
<style>
|
||||
.checkbox-custom {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid #cbd5e1;
|
||||
border-radius: 0.25rem;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-custom.checked {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.checkbox-custom.checked::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.grid-view .search-results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user