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.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):
|
||||
"""
|
||||
A mixin that provides filtering capabilities for HTMX requests.
|
||||
"""
|
||||
"""Enhance list views to return partial templates for HTMX requests."""
|
||||
|
||||
filter_class = None
|
||||
htmx_partial_suffix = "_partial.html"
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
|
||||
return self.filterset.qs
|
||||
qs = super().get_queryset()
|
||||
if self.filter_class:
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=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):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["filter"] = self.filterset
|
||||
return context
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
if hasattr(self, "filterset"):
|
||||
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:
|
||||
return {}
|
||||
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(),
|
||||
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
|
||||
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
|
||||
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
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
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
|
||||
PARK_DETAIL_URL = "parks:park_detail"
|
||||
@@ -38,6 +42,9 @@ PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
|
||||
REQUIRED_FIELDS_ERROR = (
|
||||
"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"]
|
||||
|
||||
|
||||
@@ -461,6 +468,250 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
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):
|
||||
model = Park
|
||||
form_class = ParkForm
|
||||
@@ -517,7 +768,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
park_location, _ = ParkLocation.objects.get_or_create(
|
||||
park=self.object,
|
||||
defaults={
|
||||
"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
|
||||
* 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
|
||||
Alpine.data('toast', () => ({
|
||||
|
||||
@@ -1,774 +1,209 @@
|
||||
/**
|
||||
* ThrillWiki Road Trip Planner - Multi-park Route Planning
|
||||
*
|
||||
* This module provides road trip planning functionality with multi-park selection,
|
||||
* route visualization, distance calculations, and export capabilities
|
||||
*/
|
||||
/* Minimal Roadtrip JS helpers for HTMX-driven planner
|
||||
- Initializes map helpers when Leaflet is available
|
||||
- Exposes `RoadtripMap` global with basic marker helpers
|
||||
- Heavy client-side trip logic is intentionally moved to HTMX endpoints
|
||||
*/
|
||||
|
||||
class RoadTripPlanner {
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
mapInstance: null,
|
||||
maxParks: 20,
|
||||
enableOptimization: true,
|
||||
enableExport: true,
|
||||
apiEndpoints: {
|
||||
parks: '/api/parks/',
|
||||
route: '/api/roadtrip/route/',
|
||||
optimize: '/api/roadtrip/optimize/',
|
||||
export: '/api/roadtrip/export/'
|
||||
},
|
||||
routeOptions: {
|
||||
color: '#3B82F6',
|
||||
weight: 4,
|
||||
opacity: 0.8
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
this.container = null;
|
||||
this.mapInstance = null;
|
||||
this.selectedParks = [];
|
||||
this.routeLayer = null;
|
||||
this.parkMarkers = new Map();
|
||||
this.routePolyline = null;
|
||||
this.routeData = null;
|
||||
|
||||
this.init();
|
||||
class RoadtripMap {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.markers = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the road trip planner
|
||||
*/
|
||||
init() {
|
||||
this.container = document.getElementById(this.containerId);
|
||||
if (!this.container) {
|
||||
console.error(`Road trip container with ID '${this.containerId}' not found`);
|
||||
return;
|
||||
init(containerId, opts = {}) {
|
||||
if (typeof L === 'undefined') return;
|
||||
try {
|
||||
this.map = L.map(containerId).setView([51.505, -0.09], 5);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(this.map);
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize map', e);
|
||||
}
|
||||
|
||||
this.setupUI();
|
||||
this.bindEvents();
|
||||
|
||||
// Connect to map instance if provided
|
||||
if (this.options.mapInstance) {
|
||||
this.connectToMap(this.options.mapInstance);
|
||||
}
|
||||
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the UI components
|
||||
*/
|
||||
setupUI() {
|
||||
const html = `
|
||||
<div class="roadtrip-planner">
|
||||
<div class="roadtrip-header">
|
||||
<h3 class="roadtrip-title">
|
||||
<i class="fas fa-route"></i>
|
||||
Road Trip Planner
|
||||
</h3>
|
||||
<div class="roadtrip-controls">
|
||||
<button id="optimize-route" class="btn btn-secondary btn-sm" disabled>
|
||||
<i class="fas fa-magic"></i> Optimize Route
|
||||
</button>
|
||||
<button id="clear-route" class="btn btn-outline btn-sm" disabled>
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roadtrip-content">
|
||||
<div class="park-selection">
|
||||
<div class="search-parks">
|
||||
<input type="text" id="park-search"
|
||||
placeholder="Search parks to add..."
|
||||
class="form-input">
|
||||
<div id="park-search-results" class="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-parks">
|
||||
<h4 class="section-title">Your Route (<span id="park-count">0</span>/${this.options.maxParks})</h4>
|
||||
<div id="parks-list" class="parks-list sortable">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-map-marked-alt"></i>
|
||||
<p>Search and select parks to build your road trip route</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="route-summary" id="route-summary" style="display: none;">
|
||||
<h4 class="section-title">Trip Summary</h4>
|
||||
<div class="summary-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Total Distance:</span>
|
||||
<span id="total-distance" class="stat-value">-</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Driving Time:</span>
|
||||
<span id="total-time" class="stat-value">-</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Parks:</span>
|
||||
<span id="total-parks" class="stat-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-options">
|
||||
<button id="export-gpx" class="btn btn-outline btn-sm">
|
||||
<i class="fas fa-download"></i> Export GPX
|
||||
</button>
|
||||
<button id="export-kml" class="btn btn-outline btn-sm">
|
||||
<i class="fas fa-download"></i> Export KML
|
||||
</button>
|
||||
<button id="share-route" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-share"></i> Share Route
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = html;
|
||||
addMarker(park) {
|
||||
if (!this.map || !park || !park.latitude || !park.longitude) return;
|
||||
const id = park.id;
|
||||
if (this.markers[id]) return;
|
||||
const m = L.marker([park.latitude, park.longitude]).addTo(this.map).bindPopup(park.name);
|
||||
this.markers[id] = m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event handlers
|
||||
*/
|
||||
bindEvents() {
|
||||
// Park search
|
||||
const searchInput = document.getElementById('park-search');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
this.searchParks(e.target.value);
|
||||
}, 300);
|
||||
});
|
||||
removeMarker(parkId) {
|
||||
const m = this.markers[parkId];
|
||||
if (m && this.map) {
|
||||
this.map.removeLayer(m);
|
||||
delete this.markers[parkId];
|
||||
}
|
||||
|
||||
// Route controls
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
if (optimizeBtn) {
|
||||
optimizeBtn.addEventListener('click', () => this.optimizeRoute());
|
||||
}
|
||||
|
||||
const clearBtn = document.getElementById('clear-route');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearRoute());
|
||||
}
|
||||
|
||||
// Export buttons
|
||||
const exportGpxBtn = document.getElementById('export-gpx');
|
||||
if (exportGpxBtn) {
|
||||
exportGpxBtn.addEventListener('click', () => this.exportRoute('gpx'));
|
||||
}
|
||||
|
||||
const exportKmlBtn = document.getElementById('export-kml');
|
||||
if (exportKmlBtn) {
|
||||
exportKmlBtn.addEventListener('click', () => this.exportRoute('kml'));
|
||||
}
|
||||
|
||||
const shareBtn = document.getElementById('share-route');
|
||||
if (shareBtn) {
|
||||
shareBtn.addEventListener('click', () => this.shareRoute());
|
||||
}
|
||||
|
||||
// Make parks list sortable
|
||||
this.initializeSortable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize drag-and-drop sorting for parks list
|
||||
*/
|
||||
initializeSortable() {
|
||||
const parksList = document.getElementById('parks-list');
|
||||
if (!parksList) return;
|
||||
fitToMarkers() {
|
||||
const keys = Object.keys(this.markers);
|
||||
if (!this.map || keys.length === 0) return;
|
||||
const group = new L.featureGroup(keys.map(k => this.markers[k]));
|
||||
this.map.fitBounds(group.getBounds().pad(0.2));
|
||||
}
|
||||
|
||||
// Simple drag and drop implementation
|
||||
let draggedElement = null;
|
||||
showRoute(orderedParks = []) {
|
||||
if (!this.map || typeof L.Routing === 'undefined') return;
|
||||
// remove existing control if present
|
||||
if (this._routingControl) {
|
||||
try {
|
||||
this.map.removeControl(this._routingControl);
|
||||
} catch (e) {}
|
||||
this._routingControl = null;
|
||||
}
|
||||
|
||||
parksList.addEventListener('dragstart', (e) => {
|
||||
if (e.target.classList.contains('park-item')) {
|
||||
draggedElement = e.target;
|
||||
e.target.style.opacity = '0.5';
|
||||
}
|
||||
});
|
||||
const waypoints = orderedParks
|
||||
.filter(p => p.latitude && p.longitude)
|
||||
.map(p => L.latLng(p.latitude, p.longitude));
|
||||
|
||||
parksList.addEventListener('dragend', (e) => {
|
||||
if (e.target.classList.contains('park-item')) {
|
||||
e.target.style.opacity = '1';
|
||||
draggedElement = null;
|
||||
}
|
||||
});
|
||||
if (waypoints.length < 2) return;
|
||||
|
||||
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);
|
||||
try {
|
||||
this._routingControl = L.Routing.control({
|
||||
waypoints: waypoints,
|
||||
draggableWaypoints: false,
|
||||
addWaypoints: false,
|
||||
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}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}).addTo(this.map);
|
||||
} catch (e) {
|
||||
console.error('Routing error', e);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.options.apiEndpoints.parks}?q=${encodeURIComponent(query)}&limit=10`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.displaySearchResults(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to search parks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display park search results
|
||||
*/
|
||||
displaySearchResults(parks) {
|
||||
const resultsContainer = document.getElementById('park-search-results');
|
||||
|
||||
if (parks.length === 0) {
|
||||
resultsContainer.innerHTML = '<div class="no-results">No parks found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = parks
|
||||
.filter(park => !this.isParkSelected(park.id))
|
||||
.map(park => `
|
||||
<div class="search-result-item" data-park-id="${park.id}">
|
||||
<div class="park-info">
|
||||
<div class="park-name">${park.name}</div>
|
||||
<div class="park-location">${park.formatted_location || ''}</div>
|
||||
</div>
|
||||
<button class="add-park-btn" onclick="roadTripPlanner.addPark(${park.id})">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
resultsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a park is already selected
|
||||
*/
|
||||
isParkSelected(parkId) {
|
||||
return this.selectedParks.some(park => park.id === parkId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a park to the route
|
||||
*/
|
||||
async addPark(parkId) {
|
||||
if (this.selectedParks.length >= this.options.maxParks) {
|
||||
this.showMessage(`Maximum ${this.options.maxParks} parks allowed`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.options.apiEndpoints.parks}${parkId}/`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
const park = data.data;
|
||||
this.selectedParks.push(park);
|
||||
this.updateParksDisplay();
|
||||
this.addParkMarker(park);
|
||||
this.updateRoute();
|
||||
|
||||
// Clear search
|
||||
document.getElementById('park-search').value = '';
|
||||
document.getElementById('park-search-results').innerHTML = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add park:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a park from the route
|
||||
*/
|
||||
removePark(parkId) {
|
||||
const index = this.selectedParks.findIndex(park => park.id === parkId);
|
||||
if (index > -1) {
|
||||
this.selectedParks.splice(index, 1);
|
||||
this.updateParksDisplay();
|
||||
this.removeParkMarker(parkId);
|
||||
this.updateRoute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the parks display
|
||||
*/
|
||||
updateParksDisplay() {
|
||||
const parksList = document.getElementById('parks-list');
|
||||
const parkCount = document.getElementById('park-count');
|
||||
|
||||
parkCount.textContent = this.selectedParks.length;
|
||||
|
||||
if (this.selectedParks.length === 0) {
|
||||
parksList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-map-marked-alt"></i>
|
||||
<p>Search and select parks to build your road trip route</p>
|
||||
</div>
|
||||
`;
|
||||
this.updateControls();
|
||||
return;
|
||||
}
|
||||
|
||||
const html = this.selectedParks.map((park, index) => `
|
||||
<div class="park-item" draggable="true" data-park-id="${park.id}">
|
||||
<div class="park-number">${index + 1}</div>
|
||||
<div class="park-details">
|
||||
<div class="park-name">${park.name}</div>
|
||||
<div class="park-location">${park.formatted_location || ''}</div>
|
||||
${park.distance_from_previous ? `<div class="park-distance">${park.distance_from_previous}</div>` : ''}
|
||||
</div>
|
||||
<div class="park-actions">
|
||||
<button class="btn-icon" onclick="roadTripPlanner.removePark(${park.id})" title="Remove park">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
parksList.innerHTML = html;
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update control buttons state
|
||||
*/
|
||||
updateControls() {
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
const clearBtn = document.getElementById('clear-route');
|
||||
|
||||
const hasParks = this.selectedParks.length > 0;
|
||||
const canOptimize = this.selectedParks.length > 2;
|
||||
|
||||
if (optimizeBtn) optimizeBtn.disabled = !canOptimize;
|
||||
if (clearBtn) clearBtn.disabled = !hasParks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder parks after drag and drop
|
||||
*/
|
||||
reorderParks() {
|
||||
const parkItems = document.querySelectorAll('.park-item');
|
||||
const newOrder = [];
|
||||
|
||||
parkItems.forEach(item => {
|
||||
const parkId = parseInt(item.dataset.parkId);
|
||||
const park = this.selectedParks.find(p => p.id === parkId);
|
||||
if (park) {
|
||||
newOrder.push(park);
|
||||
}
|
||||
});
|
||||
|
||||
this.selectedParks = newOrder;
|
||||
this.updateRoute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the route visualization
|
||||
*/
|
||||
async updateRoute() {
|
||||
if (this.selectedParks.length < 2) {
|
||||
this.clearRouteVisualization();
|
||||
this.updateRouteSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parkIds = this.selectedParks.map(park => park.id);
|
||||
const response = await fetch(`${this.options.apiEndpoints.route}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ parks: parkIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.routeData = data.data;
|
||||
this.visualizeRoute(data.data);
|
||||
this.updateRouteSummary(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate route:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualize the route on the map
|
||||
*/
|
||||
visualizeRoute(routeData) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
// Clear existing route
|
||||
this.clearRouteVisualization();
|
||||
|
||||
if (routeData.coordinates) {
|
||||
// Create polyline from coordinates
|
||||
this.routePolyline = L.polyline(routeData.coordinates, this.options.routeOptions);
|
||||
this.routePolyline.addTo(this.mapInstance);
|
||||
|
||||
// Fit map to route bounds
|
||||
if (routeData.coordinates.length > 0) {
|
||||
this.mapInstance.fitBounds(this.routePolyline.getBounds(), { padding: [20, 20] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear route visualization
|
||||
*/
|
||||
clearRouteVisualization() {
|
||||
if (this.routePolyline && this.mapInstance) {
|
||||
this.mapInstance.removeLayer(this.routePolyline);
|
||||
this.routePolyline = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route summary display
|
||||
*/
|
||||
updateRouteSummary(routeData) {
|
||||
const summarySection = document.getElementById('route-summary');
|
||||
|
||||
if (!routeData || this.selectedParks.length < 2) {
|
||||
summarySection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
summarySection.style.display = 'block';
|
||||
|
||||
document.getElementById('total-distance').textContent = routeData.total_distance || '-';
|
||||
document.getElementById('total-time').textContent = routeData.total_time || '-';
|
||||
document.getElementById('total-parks').textContent = this.selectedParks.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize the route order
|
||||
*/
|
||||
async optimizeRoute() {
|
||||
if (this.selectedParks.length < 3) return;
|
||||
|
||||
try {
|
||||
const parkIds = this.selectedParks.map(park => park.id);
|
||||
const response = await fetch(`${this.options.apiEndpoints.optimize}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ parks: parkIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Reorder parks based on optimization
|
||||
const optimizedOrder = data.data.optimized_order;
|
||||
this.selectedParks = optimizedOrder.map(id =>
|
||||
this.selectedParks.find(park => park.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
this.updateParksDisplay();
|
||||
this.updateRoute();
|
||||
this.showMessage('Route optimized for shortest distance', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to optimize route:', error);
|
||||
this.showMessage('Failed to optimize route', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire route
|
||||
*/
|
||||
clearRoute() {
|
||||
this.selectedParks = [];
|
||||
this.clearAllParkMarkers();
|
||||
this.clearRouteVisualization();
|
||||
this.updateParksDisplay();
|
||||
this.updateRouteSummary(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export route in specified format
|
||||
*/
|
||||
async exportRoute(format) {
|
||||
if (!this.routeData) {
|
||||
this.showMessage('No route to export', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.options.apiEndpoints.export}${format}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parks: this.selectedParks.map(p => p.id),
|
||||
route_data: this.routeData
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `thrillwiki-roadtrip.${format}`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to export route:', error);
|
||||
this.showMessage('Failed to export route', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share the route
|
||||
*/
|
||||
shareRoute() {
|
||||
if (this.selectedParks.length === 0) {
|
||||
this.showMessage('No route to share', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const parkIds = this.selectedParks.map(p => p.id).join(',');
|
||||
const url = `${window.location.origin}/roadtrip/?parks=${parkIds}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'ThrillWiki Road Trip',
|
||||
text: `Check out this ${this.selectedParks.length}-park road trip!`,
|
||||
url: url
|
||||
});
|
||||
} else {
|
||||
// Fallback to clipboard
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.showMessage('Route URL copied to clipboard', 'success');
|
||||
}).catch(() => {
|
||||
// Manual selection fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = url;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
this.showMessage('Route URL copied to clipboard', 'success');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add park marker to map
|
||||
*/
|
||||
addParkMarker(park) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const marker = L.marker([park.latitude, park.longitude], {
|
||||
icon: this.createParkIcon(park)
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="park-popup">
|
||||
<h4>${park.name}</h4>
|
||||
<p>${park.formatted_location || ''}</p>
|
||||
<button onclick="roadTripPlanner.removePark(${park.id})" class="btn btn-sm btn-outline">
|
||||
Remove from Route
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(this.mapInstance);
|
||||
this.parkMarkers.set(park.id, marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove park marker from map
|
||||
*/
|
||||
removeParkMarker(parkId) {
|
||||
if (this.parkMarkers.has(parkId) && this.mapInstance) {
|
||||
this.mapInstance.removeLayer(this.parkMarkers.get(parkId));
|
||||
this.parkMarkers.delete(parkId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all park markers
|
||||
*/
|
||||
clearAllParkMarkers() {
|
||||
this.parkMarkers.forEach(marker => {
|
||||
if (this.mapInstance) {
|
||||
this.mapInstance.removeLayer(marker);
|
||||
}
|
||||
});
|
||||
this.parkMarkers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom icon for park marker
|
||||
*/
|
||||
createParkIcon(park) {
|
||||
const index = this.selectedParks.findIndex(p => p.id === park.id) + 1;
|
||||
|
||||
return L.divIcon({
|
||||
className: 'roadtrip-park-marker',
|
||||
html: `<div class="park-marker-inner">${index}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a map instance
|
||||
*/
|
||||
connectToMap(mapInstance) {
|
||||
this.mapInstance = mapInstance;
|
||||
this.options.mapInstance = mapInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initial data (from URL parameters)
|
||||
*/
|
||||
loadInitialData() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const parkIds = urlParams.get('parks');
|
||||
|
||||
if (parkIds) {
|
||||
const ids = parkIds.split(',').map(id => parseInt(id)).filter(id => !isNaN(id));
|
||||
this.loadParksById(ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load parks by IDs
|
||||
*/
|
||||
async loadParksById(parkIds) {
|
||||
try {
|
||||
const promises = parkIds.map(id =>
|
||||
fetch(`${this.options.apiEndpoints.parks}${id}/`)
|
||||
.then(res => res.json())
|
||||
.then(data => data.status === 'success' ? data.data : null)
|
||||
);
|
||||
|
||||
const parks = (await Promise.all(promises)).filter(Boolean);
|
||||
|
||||
this.selectedParks = parks;
|
||||
this.updateParksDisplay();
|
||||
|
||||
// Add markers and update route
|
||||
parks.forEach(park => this.addParkMarker(park));
|
||||
this.updateRoute();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load parks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token for POST requests
|
||||
*/
|
||||
getCsrfToken() {
|
||||
const token = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
return token ? token.value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show message to user
|
||||
*/
|
||||
showMessage(message, type = 'info') {
|
||||
// Create or update message element
|
||||
let messageEl = this.container.querySelector('.roadtrip-message');
|
||||
if (!messageEl) {
|
||||
messageEl = document.createElement('div');
|
||||
messageEl.className = 'roadtrip-message';
|
||||
this.container.insertBefore(messageEl, this.container.firstChild);
|
||||
}
|
||||
|
||||
messageEl.textContent = message;
|
||||
messageEl.className = `roadtrip-message roadtrip-message-${type}`;
|
||||
|
||||
// Auto-hide after delay
|
||||
setTimeout(() => {
|
||||
if (messageEl.parentNode) {
|
||||
messageEl.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize road trip planner
|
||||
// Expose simple global for templates to call
|
||||
globalThis.RoadtripMap = new RoadtripMap();
|
||||
|
||||
// Backwards-compatible lightweight planner shim used by other scripts
|
||||
class RoadTripPlannerShim {
|
||||
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() {
|
||||
const roadtripContainer = document.getElementById('roadtrip-planner');
|
||||
if (roadtripContainer) {
|
||||
window.roadTripPlanner = new RoadTripPlanner('roadtrip-planner', {
|
||||
mapInstance: window.thrillwikiMap || null
|
||||
});
|
||||
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
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = RoadTripPlanner;
|
||||
} else {
|
||||
window.RoadTripPlanner = RoadTripPlanner;
|
||||
}
|
||||
function getCookie(name) {
|
||||
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||
if (match) return decodeURIComponent(match[2]);
|
||||
return null;
|
||||
}
|
||||
|
||||
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 account socialaccount %}
|
||||
|
||||
<!-- Auth Modal Component -->
|
||||
<div
|
||||
x-data="authModal()"
|
||||
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>
|
||||
<!-- HTMX-driven Auth Modal Container -->
|
||||
{# This modal no longer manages form submission client-side. Forms are fetched
|
||||
and submitted via HTMX using the account views endpoints (CustomLoginView/CustomSignupView). #}
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
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"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<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">
|
||||
<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');">
|
||||
<i class="fas fa-times w-4 h-4"></i>
|
||||
</button>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div x-show="mode === 'login'" 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">
|
||||
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>
|
||||
<!-- Content will be loaded here via HTMX -->
|
||||
<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>
|
||||
</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 %}
|
||||
Enhanced Header Component - Matches React Frontend Design
|
||||
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 -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<!-- Enhanced Search -->
|
||||
<div class="relative" x-data="searchComponent()">
|
||||
<!-- Enhanced Search (HTMX-driven) -->
|
||||
<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>
|
||||
<input
|
||||
type="search"
|
||||
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"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
hx-get="{% url 'search:search' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#search-results"
|
||||
hx-include="this"
|
||||
hx-indicator=".htmx-loading-indicator"
|
||||
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' %}
|
||||
</div>
|
||||
|
||||
<!-- Search Results Dropdown -->
|
||||
<!-- Search Results Dropdown: always present and controlled by HTMX swaps -->
|
||||
<div
|
||||
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"
|
||||
aria-live="polite"
|
||||
>
|
||||
<!-- Search results will be populated by HTMX -->
|
||||
</div>
|
||||
@@ -239,13 +237,19 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
{% else %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<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"
|
||||
>
|
||||
Sign In
|
||||
</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"
|
||||
>
|
||||
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"
|
||||
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..."
|
||||
hx-get="{% url 'parks:htmx_search_parks' %}"
|
||||
hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#park-search-results"
|
||||
hx-indicator="#search-loading">
|
||||
@@ -166,18 +166,20 @@
|
||||
</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 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trip Itinerary -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
||||
<button id="clear-trip"
|
||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
onclick="tripPlanner.clearTrip()">
|
||||
hx-post="{% url 'parks:htmx_clear_trip' %}"
|
||||
hx-target="#trip-parks"
|
||||
hx-swap="innerHTML">
|
||||
<i class="mr-1 fas fa-trash"></i>Clear All
|
||||
</button>
|
||||
</div>
|
||||
@@ -190,15 +192,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="mt-4 space-y-2">
|
||||
<button id="optimize-route"
|
||||
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.optimizeRoute()" disabled>
|
||||
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
|
||||
</button>
|
||||
<button id="calculate-route"
|
||||
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.calculateRoute()" disabled>
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@@ -230,7 +238,10 @@
|
||||
<div class="mt-4">
|
||||
<button id="save-trip"
|
||||
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
onclick="tripPlanner.saveTrip()">
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@@ -245,12 +256,12 @@
|
||||
<div class="flex gap-2">
|
||||
<button id="fit-route"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.fitRoute()">
|
||||
onclick="(window.roadTripPlanner||{}).fitRoute()">
|
||||
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
||||
</button>
|
||||
<button id="toggle-parks"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.toggleAllParks()">
|
||||
onclick="(window.roadTripPlanner||{}).toggleAllParks()">
|
||||
<i class="mr-1 fas fa-eye"></i>Show All Parks
|
||||
</button>
|
||||
</div>
|
||||
@@ -306,483 +317,12 @@
|
||||
<!-- Sortable JS for drag & drop -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<script src="{% static 'js/roadtrip.js' %}"></script>
|
||||
<script>
|
||||
// Road Trip Planner class
|
||||
class TripPlanner {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.tripParks = [];
|
||||
this.allParks = [];
|
||||
this.parkMarkers = {};
|
||||
this.routeControl = null;
|
||||
this.showingAllParks = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
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() {
|
||||
window.tripPlanner = new TripPlanner();
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
if (globalThis.RoadtripMap) {
|
||||
globalThis.RoadtripMap.init('map-container');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% 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:
|
||||
# Get the first file and update the database
|
||||
# record
|
||||
file_path = os.path.join(
|
||||
content_type, identifier, files[0]
|
||||
)
|
||||
# Get the first file and update the database record
|
||||
file_path = os.path.join(content_type, identifier, files[0])
|
||||
if os.path.exists(os.path.join("media", file_path)):
|
||||
photo.image.name = file_path
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"Updated path for photo {
|
||||
photo.id} to {file_path}"
|
||||
f"Updated path for photo {photo.id} to {file_path}"
|
||||
)
|
||||
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(
|
||||
f"File not found for photo {
|
||||
photo.id}: {file_path}"
|
||||
f"File missing for photo {photo.id}; set placeholder {placeholder}"
|
||||
)
|
||||
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(
|
||||
f"No files found in directory for photo {
|
||||
photo.id}: {media_dir}"
|
||||
f"No files in {media_dir} for photo {photo.id}; set placeholder {placeholder}"
|
||||
)
|
||||
else:
|
||||
# Directory missing -> set placeholder
|
||||
placeholder = os.path.join("placeholders", "default.svg")
|
||||
photo.image.name = placeholder
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"Directory not found for photo {
|
||||
photo.id}: {media_dir}"
|
||||
f"Directory not found for photo {photo.id}: {media_dir}; set placeholder {placeholder}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -49,9 +49,17 @@ class Command(BaseCommand):
|
||||
if files:
|
||||
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):
|
||||
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
|
||||
|
||||
# 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 |