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.
This commit is contained in:
pacnpal
2025-12-19 19:53:20 -05:00
parent bf04e4d854
commit b9063ff4f8
154 changed files with 4536 additions and 2570 deletions

View 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)

View 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

View 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

View File

@@ -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

View 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)

View 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

View File

@@ -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)

View File

@@ -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"),

View File

@@ -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", ""),

View File

@@ -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', () => ({

View File

@@ -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: '&copy; 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;
// Simple drag and drop implementation
let draggedElement = null;
parksList.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('park-item')) {
draggedElement = e.target;
e.target.style.opacity = '0.5';
}
});
parksList.addEventListener('dragend', (e) => {
if (e.target.classList.contains('park-item')) {
e.target.style.opacity = '1';
draggedElement = null;
}
});
parksList.addEventListener('dragover', (e) => {
e.preventDefault();
});
parksList.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedElement && e.target.classList.contains('park-item')) {
const afterElement = this.getDragAfterElement(parksList, e.clientY);
if (afterElement == null) {
parksList.appendChild(draggedElement);
} else {
parksList.insertBefore(draggedElement, afterElement);
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));
}
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;
}
const waypoints = orderedParks
.filter(p => p.latitude && p.longitude)
.map(p => L.latLng(p.latitude, p.longitude));
if (waypoints.length < 2) return;
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

View File

@@ -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`).
#}

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -0,0 +1 @@
<div class="field-display-value">{{ value }}</div>

View File

@@ -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

View 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>

View File

@@ -0,0 +1,5 @@
{% extends "components/modals/modal_base.html" %}
{% block modal_content %}
{% include "htmx/components/confirm_dialog.html" %}
{% endblock %}

View 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 %}

View File

@@ -0,0 +1,3 @@
<div class="modal-loading">
{% include "htmx/components/loading_indicator.html" %}
</div>

View 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>

View File

@@ -0,0 +1 @@
<div class="search-empty">No results found.</div>

View File

@@ -0,0 +1,4 @@
<div class="search-result-item">
<a href="{{ item.url }}">{{ item.title }}</a>
<div class="muted">{{ item.subtitle }}</div>
</div>

View File

@@ -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>

View 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 %}

View File

@@ -0,0 +1,7 @@
{% if errors %}
<ul class="field-errors">
{% for e in errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1 @@
<div class="field-success" aria-hidden="true"></div>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
<span class="filter-badge" role="status">
{{ label }}
<button hx-get="{{ remove_url }}" hx-swap="outerHTML">×</button>
</span>

View 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>

View File

@@ -0,0 +1,3 @@
<div class="htmx-loading-indicator" aria-hidden="true">
<div class="spinner">Loading…</div>
</div>

View 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>

View File

@@ -0,0 +1,3 @@
<div class="htmx-success" role="status" aria-live="polite">
<p>{{ message }}</p>
</div>

View 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 %}

View 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>

View 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>

View 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>

View 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>

View File

@@ -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, '&quot;')})"
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
Add to Trip
</button>
</div>
`);
marker.addTo(this.map);
this.parkMarkers[`all_${park.id}`] = marker;
}
});
}
updateButtons() {
const optimizeBtn = document.getElementById('optimize-route');
const calculateBtn = document.getElementById('calculate-route');
const hasEnoughParks = this.tripParks.length >= 2;
optimizeBtn.disabled = !hasEnoughParks;
calculateBtn.disabled = !hasEnoughParks;
}
clearTrip() {
this.tripParks = [];
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
document.getElementById('trip-summary').classList.add('hidden');
}
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
View 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.

File diff suppressed because it is too large Load Diff

View 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:

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 KiB

View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Some files were not shown because too many files have changed in this diff Show More