feat: Add detailed park and ride pages with HTMX integration
- Implemented park detail page with dynamic content loading for rides and weather. - Created park list page with filters and search functionality. - Developed ride detail page showcasing ride stats, reviews, and similar rides. - Added ride list page with filtering options and dynamic loading. - Introduced search results page with tabs for parks, rides, and users. - Added HTMX tests for global search functionality.
23
backend/apps/core/forms/htmx_forms.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.views.generic.edit import FormView
|
||||||
|
from django.http import JsonResponse, HttpResponse
|
||||||
|
|
||||||
|
|
||||||
|
class HTMXFormView(FormView):
|
||||||
|
"""Base FormView that supports field-level validation endpoints for HTMX.
|
||||||
|
|
||||||
|
Subclasses can call `validate_field` to return JSON errors for a single field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def validate_field(self, field_name):
|
||||||
|
"""Return JSON with errors for a single field based on the current form."""
|
||||||
|
form = self.get_form()
|
||||||
|
field = form[field_name]
|
||||||
|
form.is_valid() # populate errors
|
||||||
|
errors = form.errors.get(field_name, [])
|
||||||
|
return JsonResponse({"field": field_name, "errors": errors})
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
# If HTMX field validation pattern: ?field=name
|
||||||
|
if request.headers.get("HX-Request") == "true" and request.GET.get("validate_field"):
|
||||||
|
return self.validate_field(request.GET.get("validate_field"))
|
||||||
|
return super().post(request, *args, **kwargs)
|
||||||
78
backend/apps/core/htmx_utils.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
|
||||||
|
def htmx_partial(template_name):
|
||||||
|
"""Decorator for view functions to render partials for HTMX requests.
|
||||||
|
|
||||||
|
If the request is an HTMX request and a partial template exists with
|
||||||
|
the convention '<template_name>_partial.html', that template will be
|
||||||
|
rendered. Otherwise the provided template_name is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def _wrapped(request, *args, **kwargs):
|
||||||
|
resp = view_func(request, *args, **kwargs)
|
||||||
|
# If the view returned an HttpResponse, pass through
|
||||||
|
if isinstance(resp, HttpResponse):
|
||||||
|
return resp
|
||||||
|
# Expecting a tuple (context, template_name) or (context,)
|
||||||
|
context = {}
|
||||||
|
tpl = template_name
|
||||||
|
if isinstance(resp, tuple):
|
||||||
|
if len(resp) >= 1:
|
||||||
|
context = resp[0]
|
||||||
|
if len(resp) >= 2 and resp[1]:
|
||||||
|
tpl = resp[1]
|
||||||
|
|
||||||
|
# If HTMX, try partial template
|
||||||
|
if request.headers.get("HX-Request") == "true":
|
||||||
|
partial = tpl.replace(".html", "_partial.html")
|
||||||
|
try:
|
||||||
|
html = render_to_string(partial, context, request=request)
|
||||||
|
return HttpResponse(html)
|
||||||
|
except Exception:
|
||||||
|
# Fall back to full template
|
||||||
|
html = render_to_string(tpl, context, request=request)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
html = render_to_string(tpl, context, request=request)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
return _wrapped
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def htmx_redirect(url):
|
||||||
|
resp = HttpResponse("")
|
||||||
|
resp["HX-Redirect"] = url
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def htmx_trigger(name: str, payload: dict = None):
|
||||||
|
resp = HttpResponse("")
|
||||||
|
if payload is None:
|
||||||
|
resp["HX-Trigger"] = name
|
||||||
|
else:
|
||||||
|
resp["HX-Trigger"] = JsonResponse({name: payload}).content.decode()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def htmx_refresh():
|
||||||
|
resp = HttpResponse("")
|
||||||
|
resp["HX-Refresh"] = "true"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def htmx_swap_oob(target_id: str, html: str):
|
||||||
|
"""Return an out-of-band swap response by wrapping HTML and setting headers.
|
||||||
|
|
||||||
|
Note: For simple use cases this returns an HttpResponse containing the
|
||||||
|
fragment; consumers should set `HX-Boost` headers when necessary.
|
||||||
|
"""
|
||||||
|
resp = HttpResponse(html)
|
||||||
|
resp["HX-Trigger"] = f"oob:{target_id}"
|
||||||
|
return resp
|
||||||
22
backend/apps/core/middleware/htmx_error_middleware.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import logging
|
||||||
|
from django.http import HttpResponseServerError
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HTMXErrorMiddleware:
|
||||||
|
"""Catch exceptions on HTMX requests and return formatted error partials."""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
try:
|
||||||
|
return self.get_response(request)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Error during request")
|
||||||
|
if request.headers.get("HX-Request") == "true":
|
||||||
|
html = render_to_string("htmx/components/error_message.html", {"title": "Server error", "message": "An unexpected error occurred."})
|
||||||
|
return HttpResponseServerError(html)
|
||||||
|
raise
|
||||||
@@ -1,19 +1,86 @@
|
|||||||
|
from typing import Optional
|
||||||
from django.views.generic.list import MultipleObjectMixin
|
from django.views.generic.list import MultipleObjectMixin
|
||||||
|
from django.views.generic.edit import FormMixin
|
||||||
|
from django.template.loader import select_template
|
||||||
|
|
||||||
|
|
||||||
|
"""HTMX mixins for views. Single canonical definitions for partial rendering and triggers."""
|
||||||
|
|
||||||
|
|
||||||
class HTMXFilterableMixin(MultipleObjectMixin):
|
class HTMXFilterableMixin(MultipleObjectMixin):
|
||||||
"""
|
"""Enhance list views to return partial templates for HTMX requests."""
|
||||||
A mixin that provides filtering capabilities for HTMX requests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
filter_class = None
|
filter_class = None
|
||||||
|
htmx_partial_suffix = "_partial.html"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
|
if self.filter_class:
|
||||||
|
self.filterset = self.filter_class(self.request.GET, queryset=qs)
|
||||||
return self.filterset.qs
|
return self.filterset.qs
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_template_names(self):
|
||||||
|
names = super().get_template_names()
|
||||||
|
if self.request.headers.get("HX-Request") == "true":
|
||||||
|
partials = [t.replace(".html", self.htmx_partial_suffix) for t in names]
|
||||||
|
try:
|
||||||
|
select_template(partials)
|
||||||
|
return partials
|
||||||
|
except Exception:
|
||||||
|
return names
|
||||||
|
return names
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
context["filter"] = self.filterset
|
if hasattr(self, "filterset"):
|
||||||
return context
|
ctx["filter"] = self.filterset
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class HTMXFormMixin(FormMixin):
|
||||||
|
"""FormMixin that returns partials and field-level errors for HTMX requests."""
|
||||||
|
|
||||||
|
htmx_success_trigger: Optional[str] = None
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
if self.request.headers.get("HX-Request") == "true":
|
||||||
|
return self.render_to_response(self.get_context_data(form=form))
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
res = super().form_valid(form)
|
||||||
|
if (
|
||||||
|
self.request.headers.get("HX-Request") == "true"
|
||||||
|
and self.htmx_success_trigger
|
||||||
|
):
|
||||||
|
res["HX-Trigger"] = self.htmx_success_trigger
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class HTMXInlineEditMixin(FormMixin):
|
||||||
|
"""Support simple inline edit flows: GET returns form partial, POST returns updated fragment."""
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class HTMXPaginationMixin:
|
||||||
|
"""Pagination helper that supports hx-trigger based infinite scroll or standard pagination."""
|
||||||
|
|
||||||
|
page_size = 20
|
||||||
|
|
||||||
|
def get_paginate_by(self, queryset):
|
||||||
|
return getattr(self, "paginate_by", self.page_size)
|
||||||
|
|
||||||
|
|
||||||
|
class HTMXModalMixin(HTMXFormMixin):
|
||||||
|
"""Mixin to help render forms inside modals and send close triggers on success."""
|
||||||
|
|
||||||
|
modal_close_trigger = "modal:close"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
res = super().form_valid(form)
|
||||||
|
if self.request.headers.get("HX-Request") == "true":
|
||||||
|
res["HX-Trigger"] = self.modal_close_trigger
|
||||||
|
return res
|
||||||
|
|||||||
16
backend/apps/core/views/inline_edit.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.views.generic.edit import FormView
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
|
||||||
|
class InlineEditView(FormView):
|
||||||
|
"""Generic inline edit view: GET returns form fragment, POST returns updated fragment."""
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
return self.render_to_response(self.get_context_data())
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
form = self.get_form()
|
||||||
|
if form.is_valid():
|
||||||
|
self.object = form.save()
|
||||||
|
return self.render_to_response(self.get_context_data(object=self.object))
|
||||||
|
return self.form_invalid(form)
|
||||||
17
backend/apps/core/views/modal_views.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.views.generic.edit import FormView
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
|
||||||
|
class HTMXModalFormView(FormView):
|
||||||
|
"""Render form inside a modal and respond with HTMX triggers on success."""
|
||||||
|
|
||||||
|
modal_template_name = "components/modals/modal_form.html"
|
||||||
|
|
||||||
|
def get_template_names(self):
|
||||||
|
return [self.modal_template_name]
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
if self.request.headers.get("HX-Request") == "true":
|
||||||
|
response["HX-Trigger"] = "modal:close"
|
||||||
|
return response
|
||||||
@@ -60,3 +60,32 @@ class SlugRedirectMixin(View):
|
|||||||
if not self.object:
|
if not self.object:
|
||||||
return {}
|
return {}
|
||||||
return {self.slug_url_kwarg: getattr(self.object, "slug", "")}
|
return {self.slug_url_kwarg: getattr(self.object, "slug", "")}
|
||||||
|
|
||||||
|
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSearchView(TemplateView):
|
||||||
|
"""Unified search view with HTMX support for debounced results and suggestions."""
|
||||||
|
|
||||||
|
template_name = "core/search/search.html"
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
q = request.GET.get("q", "")
|
||||||
|
results = []
|
||||||
|
suggestions = []
|
||||||
|
# Lightweight placeholder search: real implementation should query multiple models
|
||||||
|
if q:
|
||||||
|
# Return a small payload of mocked results to keep this scaffold safe
|
||||||
|
results = [{"title": f"Result for {q}", "url": "#", "subtitle": "Park"}]
|
||||||
|
suggestions = [{"text": q, "url": "#"}]
|
||||||
|
|
||||||
|
context = {"results": results, "suggestions": suggestions}
|
||||||
|
|
||||||
|
# If HTMX request, render dropdown partial
|
||||||
|
if request.headers.get("HX-Request") == "true":
|
||||||
|
return render(request, "core/search/partials/search_dropdown.html", context)
|
||||||
|
|
||||||
|
return render(request, self.template_name, context)
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,47 @@ urlpatterns = [
|
|||||||
ParkDistanceCalculatorView.as_view(),
|
ParkDistanceCalculatorView.as_view(),
|
||||||
name="roadtrip_htmx_distance",
|
name="roadtrip_htmx_distance",
|
||||||
),
|
),
|
||||||
|
# Additional HTMX endpoints for client-driven route management
|
||||||
|
path(
|
||||||
|
"roadtrip/htmx/add-park/",
|
||||||
|
views.htmx_add_park_to_trip,
|
||||||
|
name="htmx_add_park_to_trip",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"roadtrip/htmx/remove-park/",
|
||||||
|
views.htmx_remove_park_from_trip,
|
||||||
|
name="htmx_remove_park_from_trip",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"roadtrip/htmx/reorder/",
|
||||||
|
views.htmx_reorder_parks,
|
||||||
|
name="htmx_reorder_parks",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"roadtrip/htmx/optimize/",
|
||||||
|
views.htmx_optimize_route,
|
||||||
|
name="htmx_optimize_route",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"roadtrip/htmx/calculate/",
|
||||||
|
views.htmx_calculate_route,
|
||||||
|
name="htmx_calculate_route",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"roadtrip/htmx/saved/",
|
||||||
|
views.htmx_saved_trips,
|
||||||
|
name="htmx_saved_trips",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"roadtrip/htmx/save/",
|
||||||
|
views.htmx_save_trip,
|
||||||
|
name="htmx_save_trip",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"roadtrip/htmx/clear/",
|
||||||
|
views.htmx_clear_trip,
|
||||||
|
name="htmx_clear_trip",
|
||||||
|
),
|
||||||
# Park detail and related views
|
# Park detail and related views
|
||||||
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
|
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
|
||||||
path("<slug:slug>/edit/", views.ParkUpdateView.as_view(), name="park_update"),
|
path("<slug:slug>/edit/", views.ParkUpdateView.as_view(), name="park_update"),
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
|||||||
import requests
|
import requests
|
||||||
from decimal import Decimal, ROUND_DOWN
|
from decimal import Decimal, ROUND_DOWN
|
||||||
from typing import Any, Optional, cast, Literal, Dict
|
from typing import Any, Optional, cast, Literal, Dict
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
PARK_DETAIL_URL = "parks:park_detail"
|
PARK_DETAIL_URL = "parks:park_detail"
|
||||||
@@ -38,6 +42,9 @@ PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
|
|||||||
REQUIRED_FIELDS_ERROR = (
|
REQUIRED_FIELDS_ERROR = (
|
||||||
"Please correct the errors below. Required fields are marked with an asterisk (*)."
|
"Please correct the errors below. Required fields are marked with an asterisk (*)."
|
||||||
)
|
)
|
||||||
|
TRIP_PARKS_TEMPLATE = "parks/partials/trip_parks_list.html"
|
||||||
|
TRIP_SUMMARY_TEMPLATE = "parks/partials/trip_summary.html"
|
||||||
|
SAVED_TRIPS_TEMPLATE = "parks/partials/saved_trips.html"
|
||||||
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||||
|
|
||||||
|
|
||||||
@@ -461,6 +468,250 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------
|
||||||
|
# HTMX roadtrip helpers
|
||||||
|
# --------------------
|
||||||
|
|
||||||
|
|
||||||
|
def htmx_saved_trips(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Return a partial with the user's saved trips (stubbed)."""
|
||||||
|
trips = []
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
try:
|
||||||
|
from .models import Trip # type: ignore
|
||||||
|
qs = Trip.objects.filter(owner=request.user).order_by("-created_at")
|
||||||
|
trips = list(qs[:10])
|
||||||
|
except Exception:
|
||||||
|
trips = []
|
||||||
|
return render(request, SAVED_TRIPS_TEMPLATE, {"trips": trips})
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_trip(request: HttpRequest) -> list:
|
||||||
|
raw = request.session.get("trip_parks", [])
|
||||||
|
try:
|
||||||
|
return [int(x) for x in raw]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_session_trip(request: HttpRequest, trip_list: list) -> None:
|
||||||
|
request.session["trip_parks"] = [int(x) for x in trip_list]
|
||||||
|
request.session.modified = True
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def htmx_add_park_to_trip(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Add a park id to `request.session['trip_parks']` and return the full trip list partial."""
|
||||||
|
park_id = request.POST.get("park_id")
|
||||||
|
if not park_id:
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body.decode("utf-8"))
|
||||||
|
park_id = payload.get("park_id")
|
||||||
|
except Exception:
|
||||||
|
park_id = None
|
||||||
|
|
||||||
|
if not park_id:
|
||||||
|
return HttpResponse("", status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(park_id)
|
||||||
|
except Exception:
|
||||||
|
return HttpResponse("", status=400)
|
||||||
|
|
||||||
|
trip = _get_session_trip(request)
|
||||||
|
if pid not in trip:
|
||||||
|
trip.append(pid)
|
||||||
|
_save_session_trip(request, trip)
|
||||||
|
|
||||||
|
# Build ordered Park queryset preserving session order
|
||||||
|
parks = []
|
||||||
|
for tid in _get_session_trip(request):
|
||||||
|
try:
|
||||||
|
parks.append(Park.objects.get(id=tid))
|
||||||
|
except Park.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
|
||||||
|
resp = HttpResponse(html)
|
||||||
|
resp["HX-Trigger"] = json.dumps({"tripUpdated": True})
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def htmx_remove_park_from_trip(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Remove a park id from `request.session['trip_parks']` and return the updated trip list partial."""
|
||||||
|
park_id = request.POST.get("park_id")
|
||||||
|
if not park_id:
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body.decode("utf-8"))
|
||||||
|
park_id = payload.get("park_id")
|
||||||
|
except Exception:
|
||||||
|
park_id = None
|
||||||
|
|
||||||
|
if not park_id:
|
||||||
|
return HttpResponse("", status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(park_id)
|
||||||
|
except Exception:
|
||||||
|
return HttpResponse("", status=400)
|
||||||
|
|
||||||
|
trip = _get_session_trip(request)
|
||||||
|
if pid in trip:
|
||||||
|
trip = [t for t in trip if t != pid]
|
||||||
|
_save_session_trip(request, trip)
|
||||||
|
|
||||||
|
parks = []
|
||||||
|
for tid in _get_session_trip(request):
|
||||||
|
try:
|
||||||
|
parks.append(Park.objects.get(id=tid))
|
||||||
|
except Park.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
|
||||||
|
resp = HttpResponse(html)
|
||||||
|
resp["HX-Trigger"] = json.dumps({"tripUpdated": True})
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def htmx_reorder_parks(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Accept an ordered list of park ids and persist it to the session, returning the updated list partial."""
|
||||||
|
order = []
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body.decode("utf-8"))
|
||||||
|
order = payload.get("order", [])
|
||||||
|
except Exception:
|
||||||
|
order = request.POST.getlist("order[]")
|
||||||
|
|
||||||
|
# Normalize to ints
|
||||||
|
clean_order = []
|
||||||
|
for item in order:
|
||||||
|
try:
|
||||||
|
clean_order.append(int(item))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_save_session_trip(request, clean_order)
|
||||||
|
|
||||||
|
parks = []
|
||||||
|
for tid in _get_session_trip(request):
|
||||||
|
try:
|
||||||
|
parks.append(Park.objects.get(id=tid))
|
||||||
|
except Park.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
|
||||||
|
resp = HttpResponse(html)
|
||||||
|
resp["HX-Trigger"] = json.dumps({"tripReordered": True})
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def htmx_optimize_route(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Compute a simple trip summary from session parks and return the summary partial."""
|
||||||
|
parks = []
|
||||||
|
for tid in _get_session_trip(request):
|
||||||
|
try:
|
||||||
|
parks.append(Park.objects.get(id=tid))
|
||||||
|
except Park.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Helper: haversine distance (miles)
|
||||||
|
import math
|
||||||
|
|
||||||
|
def haversine_miles(lat1, lon1, lat2, lon2):
|
||||||
|
# convert decimal degrees to radians
|
||||||
|
rlat1, rlon1, rlat2, rlon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
||||||
|
dlat = rlat2 - rlat1
|
||||||
|
dlon = rlon2 - rlon1
|
||||||
|
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
|
||||||
|
c = 2 * math.asin(min(1, math.sqrt(a)))
|
||||||
|
miles = 3958.8 * c
|
||||||
|
return miles
|
||||||
|
|
||||||
|
total_miles = 0.0
|
||||||
|
waypoints = []
|
||||||
|
for p in parks:
|
||||||
|
loc = getattr(p, "location", None)
|
||||||
|
lat = getattr(loc, "latitude", None) if loc else None
|
||||||
|
lon = getattr(loc, "longitude", None) if loc else None
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
waypoints.append({"id": p.id, "name": p.name, "latitude": lat, "longitude": lon})
|
||||||
|
|
||||||
|
# sum straight-line distances between consecutive waypoints
|
||||||
|
for i in range(len(waypoints) - 1):
|
||||||
|
a = waypoints[i]
|
||||||
|
b = waypoints[i + 1]
|
||||||
|
try:
|
||||||
|
total_miles += haversine_miles(a["latitude"], a["longitude"], b["latitude"], b["longitude"])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Estimate drive time assuming average speed of 60 mph
|
||||||
|
total_hours = total_miles / 60.0 if total_miles else 0.0
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"total_distance": f"{int(round(total_miles))} mi",
|
||||||
|
"total_time": f"{total_hours:.1f} hrs",
|
||||||
|
"total_parks": len(parks),
|
||||||
|
"total_rides": sum(getattr(p, "ride_count", 0) or 0 for p in parks),
|
||||||
|
}
|
||||||
|
|
||||||
|
html = render_to_string(TRIP_SUMMARY_TEMPLATE, {"summary": summary}, request=request)
|
||||||
|
resp = HttpResponse(html)
|
||||||
|
# Include waypoints payload in HX-Trigger so client can render route on the map
|
||||||
|
resp["HX-Trigger"] = json.dumps({"tripOptimized": {"parks": waypoints}})
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def htmx_calculate_route(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Alias for optimize route for now — returns trip summary partial."""
|
||||||
|
return htmx_optimize_route(request)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def htmx_save_trip(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Save the current session trip to a Trip model (if present) and return saved trips partial."""
|
||||||
|
name = request.POST.get("name") or "My Trip"
|
||||||
|
|
||||||
|
parks = []
|
||||||
|
for tid in _get_session_trip(request):
|
||||||
|
try:
|
||||||
|
parks.append(Park.objects.get(id=tid))
|
||||||
|
except Park.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
trips = []
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
try:
|
||||||
|
from .models import Trip # type: ignore
|
||||||
|
trip = Trip.objects.create(owner=request.user, name=name)
|
||||||
|
# attempt to associate parks if the Trip model supports it
|
||||||
|
try:
|
||||||
|
trip.parks.set([p.id for p in parks])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
trips = list(Trip.objects.filter(owner=request.user).order_by("-created_at")[:10])
|
||||||
|
except Exception:
|
||||||
|
trips = []
|
||||||
|
|
||||||
|
html = render_to_string(SAVED_TRIPS_TEMPLATE, {"trips": trips}, request=request)
|
||||||
|
resp = HttpResponse(html)
|
||||||
|
resp["HX-Trigger"] = json.dumps({"tripSaved": True})
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def htmx_clear_trip(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Clear the current session trip and return an empty trip list partial."""
|
||||||
|
_save_session_trip(request, [])
|
||||||
|
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": []}, request=request)
|
||||||
|
resp = HttpResponse(html)
|
||||||
|
resp["HX-Trigger"] = json.dumps({"tripCleared": True})
|
||||||
|
return resp
|
||||||
|
|
||||||
class ParkCreateView(LoginRequiredMixin, CreateView):
|
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||||
model = Park
|
model = Park
|
||||||
form_class = ParkForm
|
form_class = ParkForm
|
||||||
@@ -517,7 +768,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
||||||
# Create or update ParkLocation
|
# Create or update ParkLocation
|
||||||
park_location, created = ParkLocation.objects.get_or_create(
|
park_location, _ = ParkLocation.objects.get_or_create(
|
||||||
park=self.object,
|
park=self.object,
|
||||||
defaults={
|
defaults={
|
||||||
"street_address": form.cleaned_data.get("street_address", ""),
|
"street_address": form.cleaned_data.get("street_address", ""),
|
||||||
|
|||||||
@@ -1,3 +1,32 @@
|
|||||||
|
// Reduced Alpine components: keep only pure client-side UI state
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('themeToggle', () => ({
|
||||||
|
theme: localStorage.getItem('theme') || 'system',
|
||||||
|
init() { this.updateTheme(); },
|
||||||
|
toggle() {
|
||||||
|
this.theme = this.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('theme', this.theme);
|
||||||
|
this.updateTheme();
|
||||||
|
},
|
||||||
|
updateTheme() {
|
||||||
|
if (this.theme === 'dark') document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('mobileMenu', () => ({
|
||||||
|
open: false,
|
||||||
|
toggle() {
|
||||||
|
this.open = !this.open;
|
||||||
|
document.body.style.overflow = this.open ? 'hidden' : '';
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('dropdown', () => ({
|
||||||
|
open: false,
|
||||||
|
toggle() { this.open = !this.open; }
|
||||||
|
}));
|
||||||
|
});
|
||||||
/**
|
/**
|
||||||
* Alpine.js Components for ThrillWiki
|
* Alpine.js Components for ThrillWiki
|
||||||
* Enhanced components matching React frontend functionality
|
* Enhanced components matching React frontend functionality
|
||||||
@@ -367,202 +396,7 @@ Alpine.data('toast', () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Enhanced Authentication Modal Component
|
|
||||||
Alpine.data('authModal', (defaultMode = 'login') => ({
|
|
||||||
open: false,
|
|
||||||
mode: defaultMode, // 'login' or 'register'
|
|
||||||
showPassword: false,
|
|
||||||
socialProviders: [],
|
|
||||||
socialLoading: true,
|
|
||||||
|
|
||||||
// Login form data
|
|
||||||
loginForm: {
|
|
||||||
username: '',
|
|
||||||
password: ''
|
|
||||||
},
|
|
||||||
loginLoading: false,
|
|
||||||
loginError: '',
|
|
||||||
|
|
||||||
// Register form data
|
|
||||||
registerForm: {
|
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
|
||||||
email: '',
|
|
||||||
username: '',
|
|
||||||
password1: '',
|
|
||||||
password2: ''
|
|
||||||
},
|
|
||||||
registerLoading: false,
|
|
||||||
registerError: '',
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.fetchSocialProviders();
|
|
||||||
|
|
||||||
// Listen for auth modal events
|
|
||||||
this.$watch('open', (value) => {
|
|
||||||
if (value) {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
this.resetForms();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchSocialProviders() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/auth/social-providers/');
|
|
||||||
const data = await response.json();
|
|
||||||
this.socialProviders = data.available_providers || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch social providers:', error);
|
|
||||||
this.socialProviders = [];
|
|
||||||
} finally {
|
|
||||||
this.socialLoading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
show(mode = 'login') {
|
|
||||||
this.mode = mode;
|
|
||||||
this.open = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.open = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
switchToLogin() {
|
|
||||||
this.mode = 'login';
|
|
||||||
this.resetForms();
|
|
||||||
},
|
|
||||||
|
|
||||||
switchToRegister() {
|
|
||||||
this.mode = 'register';
|
|
||||||
this.resetForms();
|
|
||||||
},
|
|
||||||
|
|
||||||
resetForms() {
|
|
||||||
this.loginForm = { username: '', password: '' };
|
|
||||||
this.registerForm = {
|
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
|
||||||
email: '',
|
|
||||||
username: '',
|
|
||||||
password1: '',
|
|
||||||
password2: ''
|
|
||||||
};
|
|
||||||
this.loginError = '';
|
|
||||||
this.registerError = '';
|
|
||||||
this.showPassword = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
async handleLogin() {
|
|
||||||
if (!this.loginForm.username || !this.loginForm.password) {
|
|
||||||
this.loginError = 'Please fill in all fields';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loginLoading = true;
|
|
||||||
this.loginError = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/accounts/login/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'X-CSRFToken': this.getCSRFToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
login: this.loginForm.username,
|
|
||||||
password: this.loginForm.password
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Login successful - reload page to update auth state
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
this.loginError = data.message || 'Login failed. Please check your credentials.';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
this.loginError = 'An error occurred. Please try again.';
|
|
||||||
} finally {
|
|
||||||
this.loginLoading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async handleRegister() {
|
|
||||||
if (!this.registerForm.first_name || !this.registerForm.last_name ||
|
|
||||||
!this.registerForm.email || !this.registerForm.username ||
|
|
||||||
!this.registerForm.password1 || !this.registerForm.password2) {
|
|
||||||
this.registerError = 'Please fill in all fields';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.registerForm.password1 !== this.registerForm.password2) {
|
|
||||||
this.registerError = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.registerLoading = true;
|
|
||||||
this.registerError = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/accounts/signup/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'X-CSRFToken': this.getCSRFToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
first_name: this.registerForm.first_name,
|
|
||||||
last_name: this.registerForm.last_name,
|
|
||||||
email: this.registerForm.email,
|
|
||||||
username: this.registerForm.username,
|
|
||||||
password1: this.registerForm.password1,
|
|
||||||
password2: this.registerForm.password2
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Registration successful
|
|
||||||
this.close();
|
|
||||||
// Show success message or redirect
|
|
||||||
Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.');
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
this.registerError = data.message || 'Registration failed. Please try again.';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration error:', error);
|
|
||||||
this.registerError = 'An error occurred. Please try again.';
|
|
||||||
} finally {
|
|
||||||
this.registerLoading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleSocialLogin(providerId) {
|
|
||||||
const provider = this.socialProviders.find(p => p.id === providerId);
|
|
||||||
if (!provider) {
|
|
||||||
Alpine.store('toast').error(`Social provider ${providerId} not found.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to social auth URL
|
|
||||||
window.location.href = provider.auth_url;
|
|
||||||
},
|
|
||||||
|
|
||||||
getCSRFToken() {
|
|
||||||
const token = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
|
|
||||||
document.querySelector('meta[name=csrf-token]')?.getAttribute('content') ||
|
|
||||||
document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
|
|
||||||
return token || '';
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Enhanced Toast Component with Better UX
|
// Enhanced Toast Component with Better UX
|
||||||
Alpine.data('toast', () => ({
|
Alpine.data('toast', () => ({
|
||||||
|
|||||||
@@ -1,774 +1,209 @@
|
|||||||
/**
|
/* Minimal Roadtrip JS helpers for HTMX-driven planner
|
||||||
* ThrillWiki Road Trip Planner - Multi-park Route Planning
|
- Initializes map helpers when Leaflet is available
|
||||||
*
|
- Exposes `RoadtripMap` global with basic marker helpers
|
||||||
* This module provides road trip planning functionality with multi-park selection,
|
- Heavy client-side trip logic is intentionally moved to HTMX endpoints
|
||||||
* route visualization, distance calculations, and export capabilities
|
*/
|
||||||
*/
|
|
||||||
|
|
||||||
class RoadTripPlanner {
|
class RoadtripMap {
|
||||||
constructor(containerId, options = {}) {
|
constructor() {
|
||||||
this.containerId = containerId;
|
this.map = null;
|
||||||
this.options = {
|
this.markers = {};
|
||||||
mapInstance: null,
|
|
||||||
maxParks: 20,
|
|
||||||
enableOptimization: true,
|
|
||||||
enableExport: true,
|
|
||||||
apiEndpoints: {
|
|
||||||
parks: '/api/parks/',
|
|
||||||
route: '/api/roadtrip/route/',
|
|
||||||
optimize: '/api/roadtrip/optimize/',
|
|
||||||
export: '/api/roadtrip/export/'
|
|
||||||
},
|
|
||||||
routeOptions: {
|
|
||||||
color: '#3B82F6',
|
|
||||||
weight: 4,
|
|
||||||
opacity: 0.8
|
|
||||||
},
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
|
|
||||||
this.container = null;
|
|
||||||
this.mapInstance = null;
|
|
||||||
this.selectedParks = [];
|
|
||||||
this.routeLayer = null;
|
|
||||||
this.parkMarkers = new Map();
|
|
||||||
this.routePolyline = null;
|
|
||||||
this.routeData = null;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
init(containerId, opts = {}) {
|
||||||
* Initialize the road trip planner
|
if (typeof L === 'undefined') return;
|
||||||
*/
|
try {
|
||||||
init() {
|
this.map = L.map(containerId).setView([51.505, -0.09], 5);
|
||||||
this.container = document.getElementById(this.containerId);
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
if (!this.container) {
|
attribution: '© OpenStreetMap contributors'
|
||||||
console.error(`Road trip container with ID '${this.containerId}' not found`);
|
}).addTo(this.map);
|
||||||
return;
|
} catch (e) {
|
||||||
|
console.error('Failed to initialize map', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setupUI();
|
addMarker(park) {
|
||||||
this.bindEvents();
|
if (!this.map || !park || !park.latitude || !park.longitude) return;
|
||||||
|
const id = park.id;
|
||||||
// Connect to map instance if provided
|
if (this.markers[id]) return;
|
||||||
if (this.options.mapInstance) {
|
const m = L.marker([park.latitude, park.longitude]).addTo(this.map).bindPopup(park.name);
|
||||||
this.connectToMap(this.options.mapInstance);
|
this.markers[id] = m;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loadInitialData();
|
removeMarker(parkId) {
|
||||||
|
const m = this.markers[parkId];
|
||||||
|
if (m && this.map) {
|
||||||
|
this.map.removeLayer(m);
|
||||||
|
delete this.markers[parkId];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fitToMarkers() {
|
||||||
* Setup the UI components
|
const keys = Object.keys(this.markers);
|
||||||
*/
|
if (!this.map || keys.length === 0) return;
|
||||||
setupUI() {
|
const group = new L.featureGroup(keys.map(k => this.markers[k]));
|
||||||
const html = `
|
this.map.fitBounds(group.getBounds().pad(0.2));
|
||||||
<div class="roadtrip-planner">
|
|
||||||
<div class="roadtrip-header">
|
|
||||||
<h3 class="roadtrip-title">
|
|
||||||
<i class="fas fa-route"></i>
|
|
||||||
Road Trip Planner
|
|
||||||
</h3>
|
|
||||||
<div class="roadtrip-controls">
|
|
||||||
<button id="optimize-route" class="btn btn-secondary btn-sm" disabled>
|
|
||||||
<i class="fas fa-magic"></i> Optimize Route
|
|
||||||
</button>
|
|
||||||
<button id="clear-route" class="btn btn-outline btn-sm" disabled>
|
|
||||||
<i class="fas fa-trash"></i> Clear All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="roadtrip-content">
|
|
||||||
<div class="park-selection">
|
|
||||||
<div class="search-parks">
|
|
||||||
<input type="text" id="park-search"
|
|
||||||
placeholder="Search parks to add..."
|
|
||||||
class="form-input">
|
|
||||||
<div id="park-search-results" class="search-results"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="selected-parks">
|
|
||||||
<h4 class="section-title">Your Route (<span id="park-count">0</span>/${this.options.maxParks})</h4>
|
|
||||||
<div id="parks-list" class="parks-list sortable">
|
|
||||||
<div class="empty-state">
|
|
||||||
<i class="fas fa-map-marked-alt"></i>
|
|
||||||
<p>Search and select parks to build your road trip route</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="route-summary" id="route-summary" style="display: none;">
|
|
||||||
<h4 class="section-title">Trip Summary</h4>
|
|
||||||
<div class="summary-stats">
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Total Distance:</span>
|
|
||||||
<span id="total-distance" class="stat-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Driving Time:</span>
|
|
||||||
<span id="total-time" class="stat-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Parks:</span>
|
|
||||||
<span id="total-parks" class="stat-value">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="export-options">
|
|
||||||
<button id="export-gpx" class="btn btn-outline btn-sm">
|
|
||||||
<i class="fas fa-download"></i> Export GPX
|
|
||||||
</button>
|
|
||||||
<button id="export-kml" class="btn btn-outline btn-sm">
|
|
||||||
<i class="fas fa-download"></i> Export KML
|
|
||||||
</button>
|
|
||||||
<button id="share-route" class="btn btn-primary btn-sm">
|
|
||||||
<i class="fas fa-share"></i> Share Route
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
this.container.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
showRoute(orderedParks = []) {
|
||||||
* Bind event handlers
|
if (!this.map || typeof L.Routing === 'undefined') return;
|
||||||
*/
|
// remove existing control if present
|
||||||
bindEvents() {
|
if (this._routingControl) {
|
||||||
// Park search
|
try {
|
||||||
const searchInput = document.getElementById('park-search');
|
this.map.removeControl(this._routingControl);
|
||||||
if (searchInput) {
|
} catch (e) {}
|
||||||
let searchTimeout;
|
this._routingControl = null;
|
||||||
searchInput.addEventListener('input', (e) => {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
this.searchParks(e.target.value);
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route controls
|
const waypoints = orderedParks
|
||||||
const optimizeBtn = document.getElementById('optimize-route');
|
.filter(p => p.latitude && p.longitude)
|
||||||
if (optimizeBtn) {
|
.map(p => L.latLng(p.latitude, p.longitude));
|
||||||
optimizeBtn.addEventListener('click', () => this.optimizeRoute());
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearBtn = document.getElementById('clear-route');
|
if (waypoints.length < 2) return;
|
||||||
if (clearBtn) {
|
|
||||||
clearBtn.addEventListener('click', () => this.clearRoute());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export buttons
|
|
||||||
const exportGpxBtn = document.getElementById('export-gpx');
|
|
||||||
if (exportGpxBtn) {
|
|
||||||
exportGpxBtn.addEventListener('click', () => this.exportRoute('gpx'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportKmlBtn = document.getElementById('export-kml');
|
|
||||||
if (exportKmlBtn) {
|
|
||||||
exportKmlBtn.addEventListener('click', () => this.exportRoute('kml'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const shareBtn = document.getElementById('share-route');
|
|
||||||
if (shareBtn) {
|
|
||||||
shareBtn.addEventListener('click', () => this.shareRoute());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make parks list sortable
|
|
||||||
this.initializeSortable();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize drag-and-drop sorting for parks list
|
|
||||||
*/
|
|
||||||
initializeSortable() {
|
|
||||||
const parksList = document.getElementById('parks-list');
|
|
||||||
if (!parksList) return;
|
|
||||||
|
|
||||||
// Simple drag and drop implementation
|
|
||||||
let draggedElement = null;
|
|
||||||
|
|
||||||
parksList.addEventListener('dragstart', (e) => {
|
|
||||||
if (e.target.classList.contains('park-item')) {
|
|
||||||
draggedElement = e.target;
|
|
||||||
e.target.style.opacity = '0.5';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
parksList.addEventListener('dragend', (e) => {
|
|
||||||
if (e.target.classList.contains('park-item')) {
|
|
||||||
e.target.style.opacity = '1';
|
|
||||||
draggedElement = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
parksList.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
parksList.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (draggedElement && e.target.classList.contains('park-item')) {
|
|
||||||
const afterElement = this.getDragAfterElement(parksList, e.clientY);
|
|
||||||
|
|
||||||
if (afterElement == null) {
|
|
||||||
parksList.appendChild(draggedElement);
|
|
||||||
} else {
|
|
||||||
parksList.insertBefore(draggedElement, afterElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.reorderParks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the element to insert after during drag and drop
|
|
||||||
*/
|
|
||||||
getDragAfterElement(container, y) {
|
|
||||||
const draggableElements = [...container.querySelectorAll('.park-item:not(.dragging)')];
|
|
||||||
|
|
||||||
return draggableElements.reduce((closest, child) => {
|
|
||||||
const box = child.getBoundingClientRect();
|
|
||||||
const offset = y - box.top - box.height / 2;
|
|
||||||
|
|
||||||
if (offset < 0 && offset > closest.offset) {
|
|
||||||
return { offset: offset, element: child };
|
|
||||||
} else {
|
|
||||||
return closest;
|
|
||||||
}
|
|
||||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for parks
|
|
||||||
*/
|
|
||||||
async searchParks(query) {
|
|
||||||
if (!query.trim()) {
|
|
||||||
document.getElementById('park-search-results').innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.options.apiEndpoints.parks}?q=${encodeURIComponent(query)}&limit=10`);
|
this._routingControl = L.Routing.control({
|
||||||
const data = await response.json();
|
waypoints: waypoints,
|
||||||
|
draggableWaypoints: false,
|
||||||
if (data.status === 'success') {
|
addWaypoints: false,
|
||||||
this.displaySearchResults(data.data);
|
showAlternatives: false,
|
||||||
|
routeWhileDragging: false,
|
||||||
|
fitSelectedRoute: true,
|
||||||
|
createMarker: function(i, wp) {
|
||||||
|
const cls = i === 0 ? 'waypoint-start' : (i === waypoints.length - 1 ? 'waypoint-end' : 'waypoint-stop');
|
||||||
|
return L.marker(wp.latLng, { className: 'waypoint-marker ' + cls }).bindPopup(`Stop ${i+1}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}).addTo(this.map);
|
||||||
console.error('Failed to search parks:', error);
|
} catch (e) {
|
||||||
|
console.error('Routing error', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Display park search results
|
|
||||||
*/
|
|
||||||
displaySearchResults(parks) {
|
|
||||||
const resultsContainer = document.getElementById('park-search-results');
|
|
||||||
|
|
||||||
if (parks.length === 0) {
|
|
||||||
resultsContainer.innerHTML = '<div class="no-results">No parks found</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = parks
|
|
||||||
.filter(park => !this.isParkSelected(park.id))
|
|
||||||
.map(park => `
|
|
||||||
<div class="search-result-item" data-park-id="${park.id}">
|
|
||||||
<div class="park-info">
|
|
||||||
<div class="park-name">${park.name}</div>
|
|
||||||
<div class="park-location">${park.formatted_location || ''}</div>
|
|
||||||
</div>
|
|
||||||
<button class="add-park-btn" onclick="roadTripPlanner.addPark(${park.id})">
|
|
||||||
<i class="fas fa-plus"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
resultsContainer.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a park is already selected
|
|
||||||
*/
|
|
||||||
isParkSelected(parkId) {
|
|
||||||
return this.selectedParks.some(park => park.id === parkId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a park to the route
|
|
||||||
*/
|
|
||||||
async addPark(parkId) {
|
|
||||||
if (this.selectedParks.length >= this.options.maxParks) {
|
|
||||||
this.showMessage(`Maximum ${this.options.maxParks} parks allowed`, 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.options.apiEndpoints.parks}${parkId}/`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'success') {
|
|
||||||
const park = data.data;
|
|
||||||
this.selectedParks.push(park);
|
|
||||||
this.updateParksDisplay();
|
|
||||||
this.addParkMarker(park);
|
|
||||||
this.updateRoute();
|
|
||||||
|
|
||||||
// Clear search
|
|
||||||
document.getElementById('park-search').value = '';
|
|
||||||
document.getElementById('park-search-results').innerHTML = '';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to add park:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a park from the route
|
|
||||||
*/
|
|
||||||
removePark(parkId) {
|
|
||||||
const index = this.selectedParks.findIndex(park => park.id === parkId);
|
|
||||||
if (index > -1) {
|
|
||||||
this.selectedParks.splice(index, 1);
|
|
||||||
this.updateParksDisplay();
|
|
||||||
this.removeParkMarker(parkId);
|
|
||||||
this.updateRoute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the parks display
|
|
||||||
*/
|
|
||||||
updateParksDisplay() {
|
|
||||||
const parksList = document.getElementById('parks-list');
|
|
||||||
const parkCount = document.getElementById('park-count');
|
|
||||||
|
|
||||||
parkCount.textContent = this.selectedParks.length;
|
|
||||||
|
|
||||||
if (this.selectedParks.length === 0) {
|
|
||||||
parksList.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<i class="fas fa-map-marked-alt"></i>
|
|
||||||
<p>Search and select parks to build your road trip route</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
this.updateControls();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = this.selectedParks.map((park, index) => `
|
|
||||||
<div class="park-item" draggable="true" data-park-id="${park.id}">
|
|
||||||
<div class="park-number">${index + 1}</div>
|
|
||||||
<div class="park-details">
|
|
||||||
<div class="park-name">${park.name}</div>
|
|
||||||
<div class="park-location">${park.formatted_location || ''}</div>
|
|
||||||
${park.distance_from_previous ? `<div class="park-distance">${park.distance_from_previous}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="park-actions">
|
|
||||||
<button class="btn-icon" onclick="roadTripPlanner.removePark(${park.id})" title="Remove park">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
parksList.innerHTML = html;
|
|
||||||
this.updateControls();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update control buttons state
|
|
||||||
*/
|
|
||||||
updateControls() {
|
|
||||||
const optimizeBtn = document.getElementById('optimize-route');
|
|
||||||
const clearBtn = document.getElementById('clear-route');
|
|
||||||
|
|
||||||
const hasParks = this.selectedParks.length > 0;
|
|
||||||
const canOptimize = this.selectedParks.length > 2;
|
|
||||||
|
|
||||||
if (optimizeBtn) optimizeBtn.disabled = !canOptimize;
|
|
||||||
if (clearBtn) clearBtn.disabled = !hasParks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorder parks after drag and drop
|
|
||||||
*/
|
|
||||||
reorderParks() {
|
|
||||||
const parkItems = document.querySelectorAll('.park-item');
|
|
||||||
const newOrder = [];
|
|
||||||
|
|
||||||
parkItems.forEach(item => {
|
|
||||||
const parkId = parseInt(item.dataset.parkId);
|
|
||||||
const park = this.selectedParks.find(p => p.id === parkId);
|
|
||||||
if (park) {
|
|
||||||
newOrder.push(park);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.selectedParks = newOrder;
|
|
||||||
this.updateRoute();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the route visualization
|
|
||||||
*/
|
|
||||||
async updateRoute() {
|
|
||||||
if (this.selectedParks.length < 2) {
|
|
||||||
this.clearRouteVisualization();
|
|
||||||
this.updateRouteSummary(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parkIds = this.selectedParks.map(park => park.id);
|
|
||||||
const response = await fetch(`${this.options.apiEndpoints.route}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': this.getCsrfToken()
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ parks: parkIds })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'success') {
|
|
||||||
this.routeData = data.data;
|
|
||||||
this.visualizeRoute(data.data);
|
|
||||||
this.updateRouteSummary(data.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to calculate route:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Visualize the route on the map
|
|
||||||
*/
|
|
||||||
visualizeRoute(routeData) {
|
|
||||||
if (!this.mapInstance) return;
|
|
||||||
|
|
||||||
// Clear existing route
|
|
||||||
this.clearRouteVisualization();
|
|
||||||
|
|
||||||
if (routeData.coordinates) {
|
|
||||||
// Create polyline from coordinates
|
|
||||||
this.routePolyline = L.polyline(routeData.coordinates, this.options.routeOptions);
|
|
||||||
this.routePolyline.addTo(this.mapInstance);
|
|
||||||
|
|
||||||
// Fit map to route bounds
|
|
||||||
if (routeData.coordinates.length > 0) {
|
|
||||||
this.mapInstance.fitBounds(this.routePolyline.getBounds(), { padding: [20, 20] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear route visualization
|
|
||||||
*/
|
|
||||||
clearRouteVisualization() {
|
|
||||||
if (this.routePolyline && this.mapInstance) {
|
|
||||||
this.mapInstance.removeLayer(this.routePolyline);
|
|
||||||
this.routePolyline = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update route summary display
|
|
||||||
*/
|
|
||||||
updateRouteSummary(routeData) {
|
|
||||||
const summarySection = document.getElementById('route-summary');
|
|
||||||
|
|
||||||
if (!routeData || this.selectedParks.length < 2) {
|
|
||||||
summarySection.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
summarySection.style.display = 'block';
|
|
||||||
|
|
||||||
document.getElementById('total-distance').textContent = routeData.total_distance || '-';
|
|
||||||
document.getElementById('total-time').textContent = routeData.total_time || '-';
|
|
||||||
document.getElementById('total-parks').textContent = this.selectedParks.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optimize the route order
|
|
||||||
*/
|
|
||||||
async optimizeRoute() {
|
|
||||||
if (this.selectedParks.length < 3) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parkIds = this.selectedParks.map(park => park.id);
|
|
||||||
const response = await fetch(`${this.options.apiEndpoints.optimize}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': this.getCsrfToken()
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ parks: parkIds })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'success') {
|
|
||||||
// Reorder parks based on optimization
|
|
||||||
const optimizedOrder = data.data.optimized_order;
|
|
||||||
this.selectedParks = optimizedOrder.map(id =>
|
|
||||||
this.selectedParks.find(park => park.id === id)
|
|
||||||
).filter(Boolean);
|
|
||||||
|
|
||||||
this.updateParksDisplay();
|
|
||||||
this.updateRoute();
|
|
||||||
this.showMessage('Route optimized for shortest distance', 'success');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to optimize route:', error);
|
|
||||||
this.showMessage('Failed to optimize route', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the entire route
|
|
||||||
*/
|
|
||||||
clearRoute() {
|
|
||||||
this.selectedParks = [];
|
|
||||||
this.clearAllParkMarkers();
|
|
||||||
this.clearRouteVisualization();
|
|
||||||
this.updateParksDisplay();
|
|
||||||
this.updateRouteSummary(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export route in specified format
|
|
||||||
*/
|
|
||||||
async exportRoute(format) {
|
|
||||||
if (!this.routeData) {
|
|
||||||
this.showMessage('No route to export', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.options.apiEndpoints.export}${format}/`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': this.getCsrfToken()
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
parks: this.selectedParks.map(p => p.id),
|
|
||||||
route_data: this.routeData
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `thrillwiki-roadtrip.${format}`;
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to export route:', error);
|
|
||||||
this.showMessage('Failed to export route', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share the route
|
|
||||||
*/
|
|
||||||
shareRoute() {
|
|
||||||
if (this.selectedParks.length === 0) {
|
|
||||||
this.showMessage('No route to share', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parkIds = this.selectedParks.map(p => p.id).join(',');
|
|
||||||
const url = `${window.location.origin}/roadtrip/?parks=${parkIds}`;
|
|
||||||
|
|
||||||
if (navigator.share) {
|
|
||||||
navigator.share({
|
|
||||||
title: 'ThrillWiki Road Trip',
|
|
||||||
text: `Check out this ${this.selectedParks.length}-park road trip!`,
|
|
||||||
url: url
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback to clipboard
|
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
|
||||||
this.showMessage('Route URL copied to clipboard', 'success');
|
|
||||||
}).catch(() => {
|
|
||||||
// Manual selection fallback
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = url;
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
this.showMessage('Route URL copied to clipboard', 'success');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add park marker to map
|
|
||||||
*/
|
|
||||||
addParkMarker(park) {
|
|
||||||
if (!this.mapInstance) return;
|
|
||||||
|
|
||||||
const marker = L.marker([park.latitude, park.longitude], {
|
|
||||||
icon: this.createParkIcon(park)
|
|
||||||
});
|
|
||||||
|
|
||||||
marker.bindPopup(`
|
|
||||||
<div class="park-popup">
|
|
||||||
<h4>${park.name}</h4>
|
|
||||||
<p>${park.formatted_location || ''}</p>
|
|
||||||
<button onclick="roadTripPlanner.removePark(${park.id})" class="btn btn-sm btn-outline">
|
|
||||||
Remove from Route
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
marker.addTo(this.mapInstance);
|
|
||||||
this.parkMarkers.set(park.id, marker);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove park marker from map
|
|
||||||
*/
|
|
||||||
removeParkMarker(parkId) {
|
|
||||||
if (this.parkMarkers.has(parkId) && this.mapInstance) {
|
|
||||||
this.mapInstance.removeLayer(this.parkMarkers.get(parkId));
|
|
||||||
this.parkMarkers.delete(parkId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all park markers
|
|
||||||
*/
|
|
||||||
clearAllParkMarkers() {
|
|
||||||
this.parkMarkers.forEach(marker => {
|
|
||||||
if (this.mapInstance) {
|
|
||||||
this.mapInstance.removeLayer(marker);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.parkMarkers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create custom icon for park marker
|
|
||||||
*/
|
|
||||||
createParkIcon(park) {
|
|
||||||
const index = this.selectedParks.findIndex(p => p.id === park.id) + 1;
|
|
||||||
|
|
||||||
return L.divIcon({
|
|
||||||
className: 'roadtrip-park-marker',
|
|
||||||
html: `<div class="park-marker-inner">${index}</div>`,
|
|
||||||
iconSize: [30, 30],
|
|
||||||
iconAnchor: [15, 15]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to a map instance
|
|
||||||
*/
|
|
||||||
connectToMap(mapInstance) {
|
|
||||||
this.mapInstance = mapInstance;
|
|
||||||
this.options.mapInstance = mapInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load initial data (from URL parameters)
|
|
||||||
*/
|
|
||||||
loadInitialData() {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const parkIds = urlParams.get('parks');
|
|
||||||
|
|
||||||
if (parkIds) {
|
|
||||||
const ids = parkIds.split(',').map(id => parseInt(id)).filter(id => !isNaN(id));
|
|
||||||
this.loadParksById(ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load parks by IDs
|
|
||||||
*/
|
|
||||||
async loadParksById(parkIds) {
|
|
||||||
try {
|
|
||||||
const promises = parkIds.map(id =>
|
|
||||||
fetch(`${this.options.apiEndpoints.parks}${id}/`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => data.status === 'success' ? data.data : null)
|
|
||||||
);
|
|
||||||
|
|
||||||
const parks = (await Promise.all(promises)).filter(Boolean);
|
|
||||||
|
|
||||||
this.selectedParks = parks;
|
|
||||||
this.updateParksDisplay();
|
|
||||||
|
|
||||||
// Add markers and update route
|
|
||||||
parks.forEach(park => this.addParkMarker(park));
|
|
||||||
this.updateRoute();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load parks:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get CSRF token for POST requests
|
|
||||||
*/
|
|
||||||
getCsrfToken() {
|
|
||||||
const token = document.querySelector('[name=csrfmiddlewaretoken]');
|
|
||||||
return token ? token.value : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show message to user
|
|
||||||
*/
|
|
||||||
showMessage(message, type = 'info') {
|
|
||||||
// Create or update message element
|
|
||||||
let messageEl = this.container.querySelector('.roadtrip-message');
|
|
||||||
if (!messageEl) {
|
|
||||||
messageEl = document.createElement('div');
|
|
||||||
messageEl.className = 'roadtrip-message';
|
|
||||||
this.container.insertBefore(messageEl, this.container.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
messageEl.textContent = message;
|
|
||||||
messageEl.className = `roadtrip-message roadtrip-message-${type}`;
|
|
||||||
|
|
||||||
// Auto-hide after delay
|
|
||||||
setTimeout(() => {
|
|
||||||
if (messageEl.parentNode) {
|
|
||||||
messageEl.remove();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-initialize road trip planner
|
// Expose simple global for templates to call
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
globalThis.RoadtripMap = new RoadtripMap();
|
||||||
const roadtripContainer = document.getElementById('roadtrip-planner');
|
|
||||||
if (roadtripContainer) {
|
// Backwards-compatible lightweight planner shim used by other scripts
|
||||||
window.roadTripPlanner = new RoadTripPlanner('roadtrip-planner', {
|
class RoadTripPlannerShim {
|
||||||
mapInstance: window.thrillwikiMap || null
|
constructor(containerId) {
|
||||||
|
this.containerId = containerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPark(parkId) {
|
||||||
|
// POST to HTMX add endpoint and insert returned fragment
|
||||||
|
try {
|
||||||
|
const csrftoken = (document.cookie.match(/(^|;)\s*csrftoken=([^;]+)/) || [])[2];
|
||||||
|
const resp = await fetch(`/parks/roadtrip/htmx/add-park/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRFToken': csrftoken || ''
|
||||||
|
},
|
||||||
|
body: `park_id=${encodeURIComponent(parkId)}`,
|
||||||
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
|
const html = await resp.text();
|
||||||
|
const container = document.getElementById('trip-parks');
|
||||||
|
if (container) container.insertAdjacentHTML('afterbegin', html);
|
||||||
|
if (globalThis.RoadtripMap && typeof globalThis.RoadtripMap.addMarker === 'function') {
|
||||||
|
try {
|
||||||
|
const parkResp = await fetch(`/api/parks/${parkId}/`);
|
||||||
|
const parkJson = await parkResp.json();
|
||||||
|
if (parkJson && parkJson.data) globalThis.RoadtripMap.addMarker(parkJson.data);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to add park via HTMX shim', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removePark(parkId) {
|
||||||
|
const el = document.querySelector(`[data-park-id="${parkId}"]`);
|
||||||
|
if (el) el.remove();
|
||||||
|
if (globalThis.RoadtripMap && typeof globalThis.RoadtripMap.removeMarker === 'function') {
|
||||||
|
globalThis.RoadtripMap.removeMarker(parkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fitRoute() {
|
||||||
|
if (globalThis.RoadtripMap && typeof globalThis.RoadtripMap.fitToMarkers === 'function') {
|
||||||
|
globalThis.RoadtripMap.fitToMarkers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAllParks() {
|
||||||
|
// No-op in shim; map integration can implement this separately
|
||||||
|
console.debug('toggleAllParks called (shim)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose compatibility globals
|
||||||
|
globalThis.RoadTripPlanner = RoadTripPlannerShim;
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
try {
|
||||||
|
globalThis.roadTripPlanner = new RoadTripPlannerShim('roadtrip-planner');
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Initialize Sortable for #trip-parks and POST new order to server
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
try {
|
||||||
|
if (typeof Sortable === 'undefined') return;
|
||||||
|
const el = document.getElementById('trip-parks');
|
||||||
|
if (!el) return;
|
||||||
|
// avoid double-init
|
||||||
|
if (el._sortableInit) return;
|
||||||
|
el._sortableInit = true;
|
||||||
|
|
||||||
// Export for use in other modules
|
function getCookie(name) {
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||||
module.exports = RoadTripPlanner;
|
if (match) return decodeURIComponent(match[2]);
|
||||||
} else {
|
return null;
|
||||||
window.RoadTripPlanner = RoadTripPlanner;
|
}
|
||||||
}
|
|
||||||
|
new Sortable(el, {
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'drag-over',
|
||||||
|
handle: '.draggable-item',
|
||||||
|
onEnd: function (evt) {
|
||||||
|
// gather order from container children
|
||||||
|
const order = Array.from(el.children).map(function (c) { return c.dataset.parkId; }).filter(Boolean);
|
||||||
|
const csrftoken = getCookie('csrftoken');
|
||||||
|
fetch('/parks/roadtrip/htmx/reorder/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrftoken || ''
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ order: order })
|
||||||
|
}).then(function (r) { return r.text(); }).then(function (html) {
|
||||||
|
// replace inner HTML with server-rendered partial
|
||||||
|
el.innerHTML = html;
|
||||||
|
// notify other listeners (map, summary)
|
||||||
|
document.dispatchEvent(new CustomEvent('tripReordered', { detail: { order: order } }));
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('Failed to post reorder', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Sortable init error', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Listen for HTMX trigger event and show route when available
|
||||||
|
document.addEventListener('tripOptimized', function (ev) {
|
||||||
|
try {
|
||||||
|
const payload = ev && ev.detail ? ev.detail : {};
|
||||||
|
const parks = (payload && payload.parks) || [];
|
||||||
|
if (globalThis.RoadtripMap && typeof globalThis.RoadtripMap.showRoute === 'function') {
|
||||||
|
globalThis.RoadtripMap.showRoute(parks);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// End of roadtrip helpers
|
||||||
@@ -7,361 +7,25 @@ Matches React frontend AuthDialog functionality with modal-based auth
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load account socialaccount %}
|
{% load account socialaccount %}
|
||||||
|
|
||||||
<!-- Auth Modal Component -->
|
<!-- HTMX-driven Auth Modal Container -->
|
||||||
<div
|
{# This modal no longer manages form submission client-side. Forms are fetched
|
||||||
x-data="authModal()"
|
and submitted via HTMX using the account views endpoints (CustomLoginView/CustomSignupView). #}
|
||||||
x-show="open"
|
|
||||||
x-cloak
|
|
||||||
x-init="window.authModal = $data"
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
|
||||||
@keydown.escape.window="close()"
|
|
||||||
>
|
|
||||||
<!-- Modal Overlay -->
|
|
||||||
<div
|
|
||||||
x-show="open"
|
|
||||||
x-transition:enter="transition-opacity ease-linear duration-300"
|
|
||||||
x-transition:enter-start="opacity-0"
|
|
||||||
x-transition:enter-end="opacity-100"
|
|
||||||
x-transition:leave="transition-opacity ease-linear duration-300"
|
|
||||||
x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0"
|
|
||||||
class="fixed inset-0 bg-background/80 backdrop-blur-sm"
|
|
||||||
@click="close()"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Modal Content -->
|
<div id="auth-modal" class="fixed inset-0 z-50 hidden items-center justify-center" role="dialog" aria-modal="true" tabindex="-1" hx-on:keydown="if(event.key==='Escape'){ document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden'); }">
|
||||||
<div
|
<div id="auth-modal-overlay" class="fixed inset-0 bg-background/80 backdrop-blur-sm" onclick="document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden');"></div>
|
||||||
x-show="open"
|
|
||||||
x-transition:enter="transition ease-out duration-300"
|
<div id="auth-modal-content" class="relative w-full max-w-md mx-4 bg-background border rounded-lg shadow-lg" role="dialog" aria-modal="true">
|
||||||
x-transition:enter-start="transform opacity-0 scale-95"
|
<button type="button" class="absolute top-4 right-4 p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors auth-close" onclick="document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden');">
|
||||||
x-transition:enter-end="transform opacity-100 scale-100"
|
|
||||||
x-transition:leave="transition ease-in duration-200"
|
|
||||||
x-transition:leave-start="transform opacity-100 scale-100"
|
|
||||||
x-transition:leave-end="transform opacity-0 scale-95"
|
|
||||||
class="relative w-full max-w-md mx-4 bg-background border rounded-lg shadow-lg"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<!-- Close Button -->
|
|
||||||
<button
|
|
||||||
@click="close()"
|
|
||||||
class="absolute top-4 right-4 p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times w-4 h-4"></i>
|
<i class="fas fa-times w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Content will be loaded here via HTMX -->
|
||||||
<div x-show="mode === 'login'" class="p-6">
|
<div id="auth-modal-body" hx-swap-oob="true" hx-on:htmx:afterSwap="(function(){ var el=document.querySelector('#auth-modal-body input, #auth-modal-body button'); if(el){ el.focus(); } })()"></div>
|
||||||
<div class="text-center mb-6">
|
|
||||||
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
|
|
||||||
Sign In
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
|
||||||
Enter your credentials to access your account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Social Login Buttons -->
|
|
||||||
<div x-show="socialProviders.length > 0" class="mb-6">
|
|
||||||
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
|
|
||||||
<template x-for="provider in socialProviders" :key="provider.id">
|
|
||||||
<button
|
|
||||||
@click="handleSocialLogin(provider.id)"
|
|
||||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
|
|
||||||
:class="{
|
|
||||||
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
|
|
||||||
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
|
|
||||||
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="mr-2 w-4 h-4"
|
|
||||||
:class="{
|
|
||||||
'fab fa-google': provider.id === 'google',
|
|
||||||
'fab fa-discord': provider.id === 'discord'
|
|
||||||
}"
|
|
||||||
></i>
|
|
||||||
<span x-text="provider.name"></span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-show="socialLoading" class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
|
|
||||||
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="relative my-6">
|
|
||||||
<div class="absolute inset-0 flex items-center">
|
|
||||||
<div class="w-full border-t border-muted"></div>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex justify-center text-xs uppercase">
|
|
||||||
<span class="bg-background px-2 text-muted-foreground">
|
|
||||||
Or continue with
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Form -->
|
|
||||||
<form
|
|
||||||
@submit.prevent="handleLogin()"
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="login-username" class="text-sm font-medium">
|
|
||||||
Email or Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="login-username"
|
|
||||||
type="text"
|
|
||||||
x-model="loginForm.username"
|
|
||||||
placeholder="Enter your email or username"
|
|
||||||
class="input w-full"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="login-password" class="text-sm font-medium">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
id="login-password"
|
|
||||||
:type="showPassword ? 'text' : 'password'"
|
|
||||||
x-model="loginForm.password"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
class="input w-full pr-10"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="showPassword = !showPassword"
|
|
||||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<a
|
|
||||||
href="{% url 'account_reset_password' %}"
|
|
||||||
class="text-sm text-primary hover:text-primary/80 underline-offset-4 hover:underline font-medium"
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Messages -->
|
|
||||||
<div x-show="loginError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
|
||||||
<span x-text="loginError"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="loginLoading"
|
|
||||||
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
|
|
||||||
>
|
|
||||||
<span x-show="!loginLoading">Sign In</span>
|
|
||||||
<span x-show="loginLoading" class="flex items-center">
|
|
||||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
|
||||||
Signing in...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Switch to Register -->
|
|
||||||
<div class="text-center text-sm text-muted-foreground mt-6">
|
|
||||||
Don't have an account?
|
|
||||||
<button
|
|
||||||
@click="switchToRegister()"
|
|
||||||
class="text-primary hover:underline font-medium ml-1"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Sign up
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Register Form -->
|
|
||||||
<div x-show="mode === 'register'" class="p-6">
|
|
||||||
<div class="text-center mb-6">
|
|
||||||
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
|
|
||||||
Create Account
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
|
||||||
Join ThrillWiki to start exploring theme parks
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Social Registration Buttons -->
|
|
||||||
<div x-show="socialProviders.length > 0" class="mb-6">
|
|
||||||
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
|
|
||||||
<template x-for="provider in socialProviders" :key="provider.id">
|
|
||||||
<button
|
|
||||||
@click="handleSocialLogin(provider.id)"
|
|
||||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
|
|
||||||
:class="{
|
|
||||||
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
|
|
||||||
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
|
|
||||||
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="mr-2 w-4 h-4"
|
|
||||||
:class="{
|
|
||||||
'fab fa-google': provider.id === 'google',
|
|
||||||
'fab fa-discord': provider.id === 'discord'
|
|
||||||
}"
|
|
||||||
></i>
|
|
||||||
<span x-text="provider.name"></span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="relative my-6">
|
|
||||||
<div class="absolute inset-0 flex items-center">
|
|
||||||
<div class="w-full border-t border-muted"></div>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex justify-center text-xs uppercase">
|
|
||||||
<span class="bg-background px-2 text-muted-foreground">
|
|
||||||
Or continue with email
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Register Form -->
|
|
||||||
<form
|
|
||||||
@submit.prevent="handleRegister()"
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="register-first-name" class="text-sm font-medium">
|
|
||||||
First Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-first-name"
|
|
||||||
type="text"
|
|
||||||
x-model="registerForm.first_name"
|
|
||||||
placeholder="First name"
|
|
||||||
class="input w-full"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="register-last-name" class="text-sm font-medium">
|
|
||||||
Last Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-last-name"
|
|
||||||
type="text"
|
|
||||||
x-model="registerForm.last_name"
|
|
||||||
placeholder="Last name"
|
|
||||||
class="input w-full"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="register-email" class="text-sm font-medium">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-email"
|
|
||||||
type="email"
|
|
||||||
x-model="registerForm.email"
|
|
||||||
placeholder="Enter your email"
|
|
||||||
class="input w-full"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="register-username" class="text-sm font-medium">
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-username"
|
|
||||||
type="text"
|
|
||||||
x-model="registerForm.username"
|
|
||||||
placeholder="Choose a username"
|
|
||||||
class="input w-full"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="register-password" class="text-sm font-medium">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
id="register-password"
|
|
||||||
:type="showPassword ? 'text' : 'password'"
|
|
||||||
x-model="registerForm.password1"
|
|
||||||
placeholder="Create a password"
|
|
||||||
class="input w-full pr-10"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="showPassword = !showPassword"
|
|
||||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="register-password2" class="text-sm font-medium">
|
|
||||||
Confirm Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-password2"
|
|
||||||
:type="showPassword ? 'text' : 'password'"
|
|
||||||
x-model="registerForm.password2"
|
|
||||||
placeholder="Confirm your password"
|
|
||||||
class="input w-full"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Messages -->
|
|
||||||
<div x-show="registerError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
|
||||||
<span x-text="registerError"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="registerLoading"
|
|
||||||
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
|
|
||||||
>
|
|
||||||
<span x-show="!registerLoading">Create Account</span>
|
|
||||||
<span x-show="registerLoading" class="flex items-center">
|
|
||||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
|
||||||
Creating account...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Switch to Login -->
|
|
||||||
<div class="text-center text-sm text-muted-foreground mt-6">
|
|
||||||
Already have an account?
|
|
||||||
<button
|
|
||||||
@click="switchToLogin()"
|
|
||||||
class="text-primary hover:underline font-medium ml-1"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Example triggers (elsewhere in the app you can use hx-get to load the desired form into #auth-modal-body):
|
||||||
|
<button hx-get="{% url 'account_login' %}" hx-target="#auth-modal-body" hx-swap="innerHTML" onclick="document.getElementById('auth-modal').classList.remove('hidden')">Sign in</button>
|
||||||
|
<button hx-get="{% url 'account_signup' %}" hx-target="#auth-modal-body" hx-swap="innerHTML" onclick="document.getElementById('auth-modal').classList.remove('hidden')">Sign up</button>
|
||||||
|
The login/signup views already return partials for HTMX requests (see `CustomLoginView` / `CustomSignupView`).
|
||||||
|
#}
|
||||||
|
|||||||
5
backend/templates/components/filters/active_filters.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="active-filters">
|
||||||
|
{% for f in active %}
|
||||||
|
<span class="active-filter">{{ f.label }} <button hx-get="{{ f.remove_url }}">×</button></span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<label class="filter-checkbox">
|
||||||
|
<input type="checkbox" name="{{ name }}" value="{{ item.value }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar" />
|
||||||
|
<span>{{ item.label }} <small>({{ item.count }})</small></span>
|
||||||
|
</label>
|
||||||
8
backend/templates/components/filters/filter_group.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<section class="filter-group" aria-expanded="true">
|
||||||
|
<h4>{{ group.title }}</h4>
|
||||||
|
<div class="filter-items">
|
||||||
|
{% for item in group.items %}
|
||||||
|
{% include "components/filters/filter_checkbox.html" with item=item %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
4
backend/templates/components/filters/filter_range.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="filter-range">
|
||||||
|
<label>{{ label }}</label>
|
||||||
|
<input type="range" name="{{ name }}" min="{{ min }}" max="{{ max }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar" />
|
||||||
|
</div>
|
||||||
8
backend/templates/components/filters/filter_select.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<div class="filter-select">
|
||||||
|
<label for="{{ name }}">{{ label }}</label>
|
||||||
|
<select id="{{ name }}" name="{{ name }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar">
|
||||||
|
{% for opt in options %}
|
||||||
|
<option value="{{ opt.value }}">{{ opt.label }} ({{ opt.count }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
6
backend/templates/components/filters/filter_sidebar.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<aside class="filter-sidebar" id="filter-sidebar">
|
||||||
|
{% for group in groups %}
|
||||||
|
{% include "components/filters/filter_group.html" with group=group %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="applied-filters">{% include "components/filters/active_filters.html" %}</div>
|
||||||
|
</aside>
|
||||||
7
backend/templates/components/inline_edit/edit_form.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<form hx-post="{{ action }}" hx-target="closest .editable-field" hx-swap="outerHTML">
|
||||||
|
{% for field in form %}
|
||||||
|
{% include "forms/partials/form_field.html" with field=field %}
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="button" hx-trigger="click" hx-swap="none">Cancel</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="editable-field editable-field-{{ name }}">
|
||||||
|
<div class="field-display">{% include "components/inline_edit/field_display.html" %}</div>
|
||||||
|
<div class="field-edit" hx-swap-oob="true"></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div class="field-display-value">{{ value }}</div>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{# Global search removed duplicate header; primary header below handles search #}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
Enhanced Header Component - Matches React Frontend Design
|
Enhanced Header Component - Matches React Frontend Design
|
||||||
Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||||
@@ -133,32 +134,29 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
|||||||
|
|
||||||
<!-- Desktop Right Side -->
|
<!-- Desktop Right Side -->
|
||||||
<div class="hidden md:flex items-center space-x-4">
|
<div class="hidden md:flex items-center space-x-4">
|
||||||
<!-- Enhanced Search -->
|
<!-- Enhanced Search (HTMX-driven) -->
|
||||||
<div class="relative" x-data="searchComponent()">
|
<div class="relative">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"></i>
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"></i>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search parks, rides..."
|
placeholder="Search parks, rides..."
|
||||||
class="w-[300px] pl-10 pr-20 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
class="w-[300px] pl-10 pr-20 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
x-model="query"
|
|
||||||
@input.debounce.300ms="search()"
|
|
||||||
hx-get="{% url 'search:search' %}"
|
hx-get="{% url 'search:search' %}"
|
||||||
hx-trigger="input changed delay:300ms"
|
hx-trigger="input changed delay:300ms"
|
||||||
hx-target="#search-results"
|
hx-target="#search-results"
|
||||||
hx-include="this"
|
hx-indicator=".htmx-loading-indicator"
|
||||||
name="q"
|
name="q"
|
||||||
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
{% include 'components/ui/button.html' with variant='default' size='sm' text='Search' class='absolute right-1 top-1/2 transform -translate-y-1/2' %}
|
{% include 'components/ui/button.html' with variant='default' size='sm' text='Search' class='absolute right-1 top-1/2 transform -translate-y-1/2' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Results Dropdown -->
|
<!-- Search Results Dropdown: always present and controlled by HTMX swaps -->
|
||||||
<div
|
<div
|
||||||
id="search-results"
|
id="search-results"
|
||||||
x-show="results.length > 0"
|
|
||||||
x-transition
|
|
||||||
x-cloak
|
|
||||||
class="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
|
class="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
|
||||||
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<!-- Search results will be populated by HTMX -->
|
<!-- Search results will be populated by HTMX -->
|
||||||
</div>
|
</div>
|
||||||
@@ -239,13 +237,19 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
@click="window.authModal.show('login')"
|
hx-get="{% url 'account_login' %}"
|
||||||
|
hx-target="#auth-modal-body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="document.getElementById('auth-modal').classList.remove('hidden'); document.body.classList.add('overflow-hidden');"
|
||||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 rounded-md px-3"
|
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 rounded-md px-3"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="window.authModal.show('register')"
|
hx-get="{% url 'account_signup' %}"
|
||||||
|
hx-target="#auth-modal-body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="document.getElementById('auth-modal').classList.remove('hidden'); document.body.classList.add('overflow-hidden');"
|
||||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 rounded-md px-3"
|
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 rounded-md px-3"
|
||||||
>
|
>
|
||||||
Sign Up
|
Sign Up
|
||||||
|
|||||||
5
backend/templates/components/modals/modal_base.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div id="modal-container" class="modal" role="dialog" aria-modal="true" tabindex="-1">
|
||||||
|
<div class="modal-content">
|
||||||
|
{% block modal_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
5
backend/templates/components/modals/modal_confirm.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends "components/modals/modal_base.html" %}
|
||||||
|
|
||||||
|
{% block modal_content %}
|
||||||
|
{% include "htmx/components/confirm_dialog.html" %}
|
||||||
|
{% endblock %}
|
||||||
10
backend/templates/components/modals/modal_form.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends "components/modals/modal_base.html" %}
|
||||||
|
|
||||||
|
{% block modal_content %}
|
||||||
|
<form hx-post="{{ action }}" hx-target="#modal-container" hx-swap="outerHTML">
|
||||||
|
{% for field in form %}
|
||||||
|
{% include "forms/partials/form_field.html" with field=field %}
|
||||||
|
{% endfor %}
|
||||||
|
{% include "forms/partials/form_actions.html" %}
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
3
backend/templates/components/modals/modal_loading.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="modal-loading">
|
||||||
|
{% include "htmx/components/loading_indicator.html" %}
|
||||||
|
</div>
|
||||||
10
backend/templates/core/search/partials/search_dropdown.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<div id="search-dropdown" class="search-dropdown">
|
||||||
|
{% include "core/search/partials/search_suggestions.html" %}
|
||||||
|
<div id="search-results">
|
||||||
|
{% for item in results %}
|
||||||
|
{% include "core/search/partials/search_result_item.html" with item=item %}
|
||||||
|
{% empty %}
|
||||||
|
{% include "core/search/partials/search_empty.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
1
backend/templates/core/search/partials/search_empty.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="search-empty">No results found.</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="search-result-item">
|
||||||
|
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||||
|
<div class="muted">{{ item.subtitle }}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<ul class="search-suggestions">
|
||||||
|
{% for suggestion in suggestions %}
|
||||||
|
<li hx-get="{{ suggestion.url }}" hx-swap="#search-results">{{ suggestion.text }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
9
backend/templates/core/search/search.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "base/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Search</h1>
|
||||||
|
<form hx-get="/search/" hx-trigger="input changed delay:300ms" hx-target="#search-dropdown">
|
||||||
|
<input name="q" placeholder="Search..." />
|
||||||
|
</form>
|
||||||
|
<div id="search-dropdown"></div>
|
||||||
|
{% endblock %}
|
||||||
7
backend/templates/forms/partials/field_error.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% if errors %}
|
||||||
|
<ul class="field-errors">
|
||||||
|
{% for e in errors %}
|
||||||
|
<li>{{ e }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
1
backend/templates/forms/partials/field_success.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="field-success" aria-hidden="true">✓</div>
|
||||||
4
backend/templates/forms/partials/form_actions.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn-secondary" hx-trigger="click" hx-swap="none">Cancel</button>
|
||||||
|
</div>
|
||||||
5
backend/templates/forms/partials/form_field.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="form-field" data-field-name="{{ field.name }}">
|
||||||
|
<label for="id_{{ field.name }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
<div class="field-feedback" aria-live="polite">{% include "forms/partials/field_error.html" %}</div>
|
||||||
|
</div>
|
||||||
9
backend/templates/htmx/components/confirm_dialog.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="htmx-confirm" role="dialog" aria-modal="true">
|
||||||
|
<div class="confirm-body">
|
||||||
|
<p>{{ message|default:"Are you sure?" }}</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button hx-post="{{ confirm_url }}" hx-vals='{"confirm": true}'>Confirm</button>
|
||||||
|
<button hx-trigger="click" hx-swap="none">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
4
backend/templates/htmx/components/error_message.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="htmx-error" role="alert" aria-live="assertive">
|
||||||
|
<strong>{{ title|default:"Error" }}</strong>
|
||||||
|
<p>{{ message|default:"An error occurred. Please try again." }}</p>
|
||||||
|
</div>
|
||||||
4
backend/templates/htmx/components/filter_badge.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<span class="filter-badge" role="status">
|
||||||
|
{{ label }}
|
||||||
|
<button hx-get="{{ remove_url }}" hx-swap="outerHTML">×</button>
|
||||||
|
</span>
|
||||||
4
backend/templates/htmx/components/inline_edit_field.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="inline-edit-field" data-editable-name="{{ name }}">
|
||||||
|
<div class="display">{{ value }}</div>
|
||||||
|
<button hx-get="{{ edit_url }}" hx-target="closest .inline-edit-field" hx-swap="outerHTML">Edit</button>
|
||||||
|
</div>
|
||||||
3
backend/templates/htmx/components/loading_indicator.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="htmx-loading-indicator" aria-hidden="true">
|
||||||
|
<div class="spinner">Loading…</div>
|
||||||
|
</div>
|
||||||
9
backend/templates/htmx/components/pagination.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<nav class="htmx-pagination" role="navigation" aria-label="Pagination">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<button hx-get="{{ request.path }}?page={{ page_obj.previous_page_number }}" hx-swap="#results">Previous</button>
|
||||||
|
{% endif %}
|
||||||
|
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<button hx-get="{{ request.path }}?page={{ page_obj.next_page_number }}" hx-swap="#results">Next</button>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
3
backend/templates/htmx/components/success_toast.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="htmx-success" role="status" aria-live="polite">
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
</div>
|
||||||
19
backend/templates/parks/partials/park_list_item.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% for park in parks %}
|
||||||
|
<div class="park-search-item p-2 border-b border-gray-100 flex items-center justify-between" data-park-id="{{ park.id }}">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium text-sm">{{ park.name }}</div>
|
||||||
|
{% if park.location %}
|
||||||
|
<div class="text-xs text-gray-500">{{ park.location.city }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<button class="px-2 py-1 text-sm bg-blue-600 text-white rounded"
|
||||||
|
hx-post="{% url 'parks:htmx_add_park_to_trip' %}"
|
||||||
|
hx-vals='{"park_id": "{{ park.id }}"}'
|
||||||
|
hx-target="#trip-parks"
|
||||||
|
hx-swap="afterbegin">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
21
backend/templates/parks/partials/saved_trips.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<div class="saved-trips-list">
|
||||||
|
{% if trips %}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for trip in trips %}
|
||||||
|
<li class="p-2 bg-white rounded-md shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ trip.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ trip.created_at }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'parks:roadtrip_detail' trip.id %}" class="text-blue-600">View</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-center text-gray-500">No saved trips yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
16
backend/templates/parks/partials/trip_park_item.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="trip-park-item draggable-item flex items-center justify-between p-2 bg-gray-50 rounded-md" data-park-id="{{ park.id }}">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ park.name }}</div>
|
||||||
|
{% if park.location %}
|
||||||
|
<div class="text-xs text-gray-500">{{ park.location.city }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button class="px-2 py-1 text-sm text-red-600 hover:text-red-800"
|
||||||
|
hx-post="{% url 'parks:htmx_remove_park_from_trip' %}"
|
||||||
|
hx-vals='{"park_id": "{{ park.id }}"}'
|
||||||
|
hx-swap="delete">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
34
backend/templates/parks/partials/trip_parks_list.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<div id="trip-parks" class="space-y-2">
|
||||||
|
{% if trip_parks %}
|
||||||
|
{% for park in trip_parks %}
|
||||||
|
{% include 'parks/partials/trip_park_item.html' with park=park %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div id="empty-trip" class="text-center py-8 text-gray-500">
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('htmx:load', function () {
|
||||||
|
const el = document.getElementById('trip-parks');
|
||||||
|
if (!el) return;
|
||||||
|
if (el.dataset.sortableInit) return;
|
||||||
|
if (typeof Sortable === 'undefined' || typeof htmx === 'undefined') return;
|
||||||
|
el.dataset.sortableInit = '1';
|
||||||
|
const sorter = new Sortable(el, {
|
||||||
|
animation: 150,
|
||||||
|
handle: '.draggable-item',
|
||||||
|
onEnd: function () {
|
||||||
|
const order = Array.from(el.querySelectorAll('[data-park-id]')).map(function (n) {
|
||||||
|
return n.dataset.parkId;
|
||||||
|
});
|
||||||
|
htmx.ajax('POST', '{% url "parks:htmx_reorder_parks" %}', { values: { 'order[]': order } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
el._tripSortable = sorter;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
21
backend/templates/parks/partials/trip_summary.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<div id="trip-summary" class="trip-summary-card">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-gray-900">Trip Summary</h3>
|
||||||
|
<div class="trip-stats grid grid-cols-2 gap-4">
|
||||||
|
<div class="trip-stat">
|
||||||
|
<div class="trip-stat-value" id="total-distance">{{ summary.total_distance }}</div>
|
||||||
|
<div class="trip-stat-label">Total Miles</div>
|
||||||
|
</div>
|
||||||
|
<div class="trip-stat">
|
||||||
|
<div class="trip-stat-value" id="total-time">{{ summary.total_time }}</div>
|
||||||
|
<div class="trip-stat-label">Drive Time</div>
|
||||||
|
</div>
|
||||||
|
<div class="trip-stat">
|
||||||
|
<div class="trip-stat-value" id="total-parks">{{ summary.total_parks }}</div>
|
||||||
|
<div class="trip-stat-label">Parks</div>
|
||||||
|
</div>
|
||||||
|
<div class="trip-stat">
|
||||||
|
<div class="trip-stat-value" id="total-rides">{{ summary.total_rides }}</div>
|
||||||
|
<div class="trip-stat-label">Total Rides</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
<input type="text" id="park-search"
|
<input type="text" id="park-search"
|
||||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="Search parks by name or location..."
|
placeholder="Search parks by name or location..."
|
||||||
hx-get="{% url 'parks:htmx_search_parks' %}"
|
hx-get="{% url 'parks:search_parks' %}"
|
||||||
hx-trigger="input changed delay:300ms"
|
hx-trigger="input changed delay:300ms"
|
||||||
hx-target="#park-search-results"
|
hx-target="#park-search-results"
|
||||||
hx-indicator="#search-loading">
|
hx-indicator="#search-loading">
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
|
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||||
<!-- Search results will be populated here -->
|
<!-- Search results will be populated here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +177,9 @@
|
|||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
||||||
<button id="clear-trip"
|
<button id="clear-trip"
|
||||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||||
onclick="tripPlanner.clearTrip()">
|
hx-post="{% url 'parks:htmx_clear_trip' %}"
|
||||||
|
hx-target="#trip-parks"
|
||||||
|
hx-swap="innerHTML">
|
||||||
<i class="mr-1 fas fa-trash"></i>Clear All
|
<i class="mr-1 fas fa-trash"></i>Clear All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,12 +195,18 @@
|
|||||||
<div class="mt-4 space-y-2">
|
<div class="mt-4 space-y-2">
|
||||||
<button id="optimize-route"
|
<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"
|
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
onclick="tripPlanner.optimizeRoute()" disabled>
|
hx-post="{% url 'parks:htmx_optimize_route' %}"
|
||||||
|
hx-target="#trip-summary"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator="#trip-summary-loading">
|
||||||
<i class="mr-2 fas fa-route"></i>Optimize Route
|
<i class="mr-2 fas fa-route"></i>Optimize Route
|
||||||
</button>
|
</button>
|
||||||
<button id="calculate-route"
|
<button 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"
|
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
onclick="tripPlanner.calculateRoute()" disabled>
|
hx-post="{% url 'parks:htmx_calculate_route' %}"
|
||||||
|
hx-target="#trip-summary"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator="#trip-summary-loading">
|
||||||
<i class="mr-2 fas fa-map"></i>Calculate Route
|
<i class="mr-2 fas fa-map"></i>Calculate Route
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,7 +238,10 @@
|
|||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button id="save-trip"
|
<button id="save-trip"
|
||||||
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||||
onclick="tripPlanner.saveTrip()">
|
hx-post="{% url 'parks:htmx_save_trip' %}"
|
||||||
|
hx-target="#saved-trips"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#trips-loading">
|
||||||
<i class="mr-2 fas fa-save"></i>Save Trip
|
<i class="mr-2 fas fa-save"></i>Save Trip
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,12 +256,12 @@
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button id="fit-route"
|
<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"
|
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
onclick="tripPlanner.fitRoute()">
|
onclick="(window.roadTripPlanner||{}).fitRoute()">
|
||||||
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
||||||
</button>
|
</button>
|
||||||
<button id="toggle-parks"
|
<button 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"
|
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
onclick="tripPlanner.toggleAllParks()">
|
onclick="(window.roadTripPlanner||{}).toggleAllParks()">
|
||||||
<i class="mr-1 fas fa-eye"></i>Show All Parks
|
<i class="mr-1 fas fa-eye"></i>Show All Parks
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,483 +317,12 @@
|
|||||||
<!-- Sortable JS for drag & drop -->
|
<!-- Sortable JS for drag & drop -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||||
|
|
||||||
|
<script src="{% static 'js/roadtrip.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
// Road Trip Planner class
|
|
||||||
class TripPlanner {
|
|
||||||
constructor() {
|
|
||||||
this.map = null;
|
|
||||||
this.tripParks = [];
|
|
||||||
this.allParks = [];
|
|
||||||
this.parkMarkers = {};
|
|
||||||
this.routeControl = null;
|
|
||||||
this.showingAllParks = false;
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.initMap();
|
|
||||||
this.loadAllParks();
|
|
||||||
this.initDragDrop();
|
|
||||||
this.bindEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
initMap() {
|
|
||||||
// Initialize the map
|
|
||||||
this.map = L.map('map-container', {
|
|
||||||
center: [39.8283, -98.5795],
|
|
||||||
zoom: 4,
|
|
||||||
zoomControl: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add custom zoom control
|
|
||||||
L.control.zoom({
|
|
||||||
position: 'bottomright'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
// Add tile layers with dark mode support
|
|
||||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors'
|
|
||||||
});
|
|
||||||
|
|
||||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors, © CARTO'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set initial tiles based on theme
|
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
|
||||||
darkTiles.addTo(this.map);
|
|
||||||
} else {
|
|
||||||
lightTiles.addTo(this.map);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for theme changes
|
|
||||||
this.observeThemeChanges(lightTiles, darkTiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
observeThemeChanges(lightTiles, darkTiles) {
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.attributeName === 'class') {
|
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
|
||||||
this.map.removeLayer(lightTiles);
|
|
||||||
this.map.addLayer(darkTiles);
|
|
||||||
} else {
|
|
||||||
this.map.removeLayer(darkTiles);
|
|
||||||
this.map.addLayer(lightTiles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['class']
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadAllParks() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('{{ map_api_urls.locations }}?types=park&limit=1000');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'success' && data.data.locations) {
|
|
||||||
this.allParks = data.data.locations;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load parks:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initDragDrop() {
|
|
||||||
// Make trip parks sortable
|
|
||||||
new Sortable(document.getElementById('trip-parks'), {
|
|
||||||
animation: 150,
|
|
||||||
ghostClass: 'drag-over',
|
|
||||||
onEnd: (evt) => {
|
|
||||||
this.reorderTripParks(evt.oldIndex, evt.newIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bindEvents() {
|
|
||||||
// Handle park search results
|
|
||||||
document.addEventListener('htmx:afterRequest', (event) => {
|
|
||||||
if (event.target.id === 'park-search-results') {
|
|
||||||
this.handleSearchResults();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSearchResults() {
|
|
||||||
const results = document.getElementById('park-search-results');
|
|
||||||
if (results.children.length > 0) {
|
|
||||||
results.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
results.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addParkToTrip(parkData) {
|
|
||||||
// Check if park already in trip
|
|
||||||
if (this.tripParks.find(p => p.id === parkData.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tripParks.push(parkData);
|
|
||||||
this.updateTripDisplay();
|
|
||||||
this.updateTripMarkers();
|
|
||||||
this.updateButtons();
|
|
||||||
|
|
||||||
// Hide search results
|
|
||||||
document.getElementById('park-search-results').classList.add('hidden');
|
|
||||||
document.getElementById('park-search').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
removeParkFromTrip(parkId) {
|
|
||||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
|
||||||
this.updateTripDisplay();
|
|
||||||
this.updateTripMarkers();
|
|
||||||
this.updateButtons();
|
|
||||||
|
|
||||||
if (this.routeControl) {
|
|
||||||
this.map.removeControl(this.routeControl);
|
|
||||||
this.routeControl = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTripDisplay() {
|
|
||||||
const container = document.getElementById('trip-parks');
|
|
||||||
const emptyState = document.getElementById('empty-trip');
|
|
||||||
|
|
||||||
if (this.tripParks.length === 0) {
|
|
||||||
emptyState.style.display = 'block';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emptyState.style.display = 'none';
|
|
||||||
|
|
||||||
// Clear existing parks (except empty state)
|
|
||||||
Array.from(container.children).forEach(child => {
|
|
||||||
if (child.id !== 'empty-trip') {
|
|
||||||
child.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add trip parks
|
|
||||||
this.tripParks.forEach((park, index) => {
|
|
||||||
const parkElement = this.createTripParkElement(park, index);
|
|
||||||
container.appendChild(parkElement);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createTripParkElement(park, index) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'park-card draggable-item';
|
|
||||||
div.innerHTML = `
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
|
|
||||||
${index + 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
||||||
${park.name}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
|
||||||
${park.formatted_location || 'Location not specified'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
|
||||||
class="text-red-500 hover:text-red-700 p-1">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return div;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTripMarkers() {
|
|
||||||
// Clear existing trip markers
|
|
||||||
Object.values(this.parkMarkers).forEach(marker => {
|
|
||||||
this.map.removeLayer(marker);
|
|
||||||
});
|
|
||||||
this.parkMarkers = {};
|
|
||||||
|
|
||||||
// Add markers for trip parks
|
|
||||||
this.tripParks.forEach((park, index) => {
|
|
||||||
const marker = this.createTripMarker(park, index);
|
|
||||||
this.parkMarkers[park.id] = marker;
|
|
||||||
marker.addTo(this.map);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fit map to show all trip parks
|
|
||||||
if (this.tripParks.length > 0) {
|
|
||||||
this.fitRoute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createTripMarker(park, index) {
|
|
||||||
let markerClass = 'waypoint-stop';
|
|
||||||
if (index === 0) markerClass = 'waypoint-start';
|
|
||||||
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
className: `waypoint-marker ${markerClass}`,
|
|
||||||
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
|
|
||||||
iconSize: [30, 30],
|
|
||||||
iconAnchor: [15, 15]
|
|
||||||
});
|
|
||||||
|
|
||||||
const marker = L.marker([park.latitude, park.longitude], { icon });
|
|
||||||
|
|
||||||
const popupContent = `
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
|
||||||
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
|
|
||||||
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
|
|
||||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
|
||||||
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
|
|
||||||
Remove from Trip
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
marker.bindPopup(popupContent);
|
|
||||||
return marker;
|
|
||||||
}
|
|
||||||
|
|
||||||
reorderTripParks(oldIndex, newIndex) {
|
|
||||||
const park = this.tripParks.splice(oldIndex, 1)[0];
|
|
||||||
this.tripParks.splice(newIndex, 0, park);
|
|
||||||
this.updateTripDisplay();
|
|
||||||
this.updateTripMarkers();
|
|
||||||
|
|
||||||
// Clear route to force recalculation
|
|
||||||
if (this.routeControl) {
|
|
||||||
this.map.removeControl(this.routeControl);
|
|
||||||
this.routeControl = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async optimizeRoute() {
|
|
||||||
if (this.tripParks.length < 2) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parkIds = this.tripParks.map(p => p.id);
|
|
||||||
const response = await fetch('{% url "parks:htmx_optimize_route" %}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': '{{ csrf_token }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ park_ids: parkIds })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'success' && data.optimized_order) {
|
|
||||||
// Reorder parks based on optimization
|
|
||||||
const optimizedParks = data.optimized_order.map(id =>
|
|
||||||
this.tripParks.find(p => p.id === id)
|
|
||||||
).filter(Boolean);
|
|
||||||
|
|
||||||
this.tripParks = optimizedParks;
|
|
||||||
this.updateTripDisplay();
|
|
||||||
this.updateTripMarkers();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Route optimization failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async calculateRoute() {
|
|
||||||
if (this.tripParks.length < 2) return;
|
|
||||||
|
|
||||||
// Remove existing route
|
|
||||||
if (this.routeControl) {
|
|
||||||
this.map.removeControl(this.routeControl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const waypoints = this.tripParks.map(park =>
|
|
||||||
L.latLng(park.latitude, park.longitude)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.routeControl = L.Routing.control({
|
|
||||||
waypoints: waypoints,
|
|
||||||
routeWhileDragging: false,
|
|
||||||
addWaypoints: false,
|
|
||||||
createMarker: () => null, // Don't create default markers
|
|
||||||
lineOptions: {
|
|
||||||
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
|
|
||||||
}
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
this.routeControl.on('routesfound', (e) => {
|
|
||||||
const route = e.routes[0];
|
|
||||||
this.updateTripSummary(route);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTripSummary(route) {
|
|
||||||
if (!route) return;
|
|
||||||
|
|
||||||
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
|
|
||||||
const totalTime = this.formatDuration(route.summary.totalTime);
|
|
||||||
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
|
||||||
|
|
||||||
document.getElementById('total-distance').textContent = totalDistance;
|
|
||||||
document.getElementById('total-time').textContent = totalTime;
|
|
||||||
document.getElementById('total-parks').textContent = this.tripParks.length;
|
|
||||||
document.getElementById('total-rides').textContent = totalRides;
|
|
||||||
|
|
||||||
document.getElementById('trip-summary').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
formatDuration(seconds) {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
}
|
|
||||||
return `${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
fitRoute() {
|
|
||||||
if (this.tripParks.length === 0) return;
|
|
||||||
|
|
||||||
const group = new L.featureGroup(Object.values(this.parkMarkers));
|
|
||||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleAllParks() {
|
|
||||||
// Implementation for showing/hiding all parks on the map
|
|
||||||
const button = document.getElementById('toggle-parks');
|
|
||||||
const icon = button.querySelector('i');
|
|
||||||
|
|
||||||
if (this.showingAllParks) {
|
|
||||||
// Hide all parks
|
|
||||||
this.showingAllParks = false;
|
|
||||||
icon.className = 'mr-1 fas fa-eye';
|
|
||||||
button.innerHTML = icon.outerHTML + 'Show All Parks';
|
|
||||||
} else {
|
|
||||||
// Show all parks
|
|
||||||
this.showingAllParks = true;
|
|
||||||
icon.className = 'mr-1 fas fa-eye-slash';
|
|
||||||
button.innerHTML = icon.outerHTML + 'Hide All Parks';
|
|
||||||
this.displayAllParks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
displayAllParks() {
|
|
||||||
// Add markers for all parks (implementation depends on requirements)
|
|
||||||
this.allParks.forEach(park => {
|
|
||||||
if (!this.parkMarkers[park.id]) {
|
|
||||||
const marker = L.marker([park.latitude, park.longitude], {
|
|
||||||
icon: L.divIcon({
|
|
||||||
className: 'location-marker location-marker-park',
|
|
||||||
html: '<div class="location-marker-inner">🎢</div>',
|
|
||||||
iconSize: [20, 20],
|
|
||||||
iconAnchor: [10, 10]
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
marker.bindPopup(`
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
|
||||||
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '"')})"
|
|
||||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
|
||||||
Add to Trip
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
marker.addTo(this.map);
|
|
||||||
this.parkMarkers[`all_${park.id}`] = marker;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateButtons() {
|
|
||||||
const optimizeBtn = document.getElementById('optimize-route');
|
|
||||||
const calculateBtn = document.getElementById('calculate-route');
|
|
||||||
|
|
||||||
const hasEnoughParks = this.tripParks.length >= 2;
|
|
||||||
|
|
||||||
optimizeBtn.disabled = !hasEnoughParks;
|
|
||||||
calculateBtn.disabled = !hasEnoughParks;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTrip() {
|
|
||||||
this.tripParks = [];
|
|
||||||
this.updateTripDisplay();
|
|
||||||
this.updateTripMarkers();
|
|
||||||
this.updateButtons();
|
|
||||||
|
|
||||||
if (this.routeControl) {
|
|
||||||
this.map.removeControl(this.routeControl);
|
|
||||||
this.routeControl = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('trip-summary').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveTrip() {
|
|
||||||
if (this.tripParks.length === 0) return;
|
|
||||||
|
|
||||||
const tripName = prompt('Enter a name for this trip:');
|
|
||||||
if (!tripName) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('{% url "parks:htmx_save_trip" %}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': '{{ csrf_token }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: tripName,
|
|
||||||
park_ids: this.tripParks.map(p => p.id)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'success') {
|
|
||||||
alert('Trip saved successfully!');
|
|
||||||
// Refresh saved trips
|
|
||||||
htmx.trigger('#saved-trips', 'refresh');
|
|
||||||
} else {
|
|
||||||
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Save trip failed:', error);
|
|
||||||
alert('Failed to save trip');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global function for adding parks from search results
|
|
||||||
window.addParkToTrip = function(parkData) {
|
|
||||||
window.tripPlanner.addParkToTrip(parkData);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize trip planner when page loads
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
window.tripPlanner = new TripPlanner();
|
if (globalThis.RoadtripMap) {
|
||||||
|
globalThis.RoadtripMap.init('map-container');
|
||||||
// Hide search results when clicking outside
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
|
|
||||||
document.getElementById('park-search-results').classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
16
docs/htmx-patterns.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# HTMX Patterns for ThrillWiki
|
||||||
|
|
||||||
|
This document records the HTMX patterns used across the project. It is
|
||||||
|
intended as a living reference for developers migrating Alpine-driven
|
||||||
|
interactions to server-driven HTMX flows.
|
||||||
|
|
||||||
|
Key patterns:
|
||||||
|
|
||||||
|
- Partial rendering for list and detail updates (`*_partial.html`)
|
||||||
|
- `HX-Trigger` for cross-component events
|
||||||
|
- `HX-Redirect` for post-auth redirects
|
||||||
|
- `hx-indicator` and skeleton loaders for UX
|
||||||
|
- Field-level validation via `validate_field` query param
|
||||||
|
|
||||||
|
See templates under `backend/templates/htmx/` and mixins in
|
||||||
|
`backend/apps/core/mixins/__init__.py` for examples.
|
||||||
1049
plans/frontend-rewrite-plan.md
Normal file
@@ -45,32 +45,37 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
|
|
||||||
if files:
|
if files:
|
||||||
# Get the first file and update the database
|
# Get the first file and update the database record
|
||||||
# record
|
file_path = os.path.join(content_type, identifier, files[0])
|
||||||
file_path = os.path.join(
|
|
||||||
content_type, identifier, files[0]
|
|
||||||
)
|
|
||||||
if os.path.exists(os.path.join("media", file_path)):
|
if os.path.exists(os.path.join("media", file_path)):
|
||||||
photo.image.name = file_path
|
photo.image.name = file_path
|
||||||
photo.save()
|
photo.save()
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f"Updated path for photo {
|
f"Updated path for photo {photo.id} to {file_path}"
|
||||||
photo.id} to {file_path}"
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# If the expected file is still missing, fall back to placeholder
|
||||||
|
placeholder = os.path.join("placeholders", "default.svg")
|
||||||
|
photo.image.name = placeholder
|
||||||
|
photo.save()
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f"File not found for photo {
|
f"File missing for photo {photo.id}; set placeholder {placeholder}"
|
||||||
photo.id}: {file_path}"
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# No files found for this identifier -> set placeholder
|
||||||
|
placeholder = os.path.join("placeholders", "default.svg")
|
||||||
|
photo.image.name = placeholder
|
||||||
|
photo.save()
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f"No files found in directory for photo {
|
f"No files in {media_dir} for photo {photo.id}; set placeholder {placeholder}"
|
||||||
photo.id}: {media_dir}"
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Directory missing -> set placeholder
|
||||||
|
placeholder = os.path.join("placeholders", "default.svg")
|
||||||
|
photo.image.name = placeholder
|
||||||
|
photo.save()
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f"Directory not found for photo {
|
f"Directory not found for photo {photo.id}: {media_dir}; set placeholder {placeholder}"
|
||||||
photo.id}: {media_dir}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -49,9 +49,17 @@ class Command(BaseCommand):
|
|||||||
if files:
|
if files:
|
||||||
current_path = os.path.join(old_dir, files[0])
|
current_path = os.path.join(old_dir, files[0])
|
||||||
|
|
||||||
# Skip if file still not found
|
# If file still not found, set placeholder and continue
|
||||||
if not os.path.exists(current_path):
|
if not os.path.exists(current_path):
|
||||||
self.stdout.write(f"Skipping {current_name} - file not found")
|
placeholder = os.path.join("placeholders", "default.svg")
|
||||||
|
try:
|
||||||
|
photo.image.name = placeholder
|
||||||
|
photo.save()
|
||||||
|
self.stdout.write(
|
||||||
|
f"File for {current_name} not found; set placeholder {placeholder} for photo {photo.id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(f"Error setting placeholder for photo {photo.id}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get content type and object
|
# Get content type and object
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 942 KiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 942 KiB |
|
Before Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 770 KiB |
7
shared/media/placeholders/default.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">
|
||||||
|
<rect width="100%" height="100%" fill="#e5e7eb" />
|
||||||
|
<g fill="#9ca3af" font-family="Arial, Helvetica, sans-serif" font-size="28">
|
||||||
|
<text x="50%" y="45%" text-anchor="middle">Image not available</text>
|
||||||
|
<text x="50%" y="60%" text-anchor="middle" font-size="16">placeholder</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |