Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9063ff4f8 | ||
|
|
bf04e4d854 | ||
|
|
1b246eeaa4 |
51
apps/accounts/admin.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import HttpRequest
|
||||
from django.db.models import QuerySet
|
||||
|
||||
# Import models from the backend location
|
||||
from backend.apps.accounts.models import (
|
||||
User,
|
||||
UserProfile,
|
||||
EmailVerification,
|
||||
)
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
list_display = ('username', 'email', 'user_id', 'role', 'is_active', 'is_staff', 'date_joined')
|
||||
list_filter = ('role', 'is_active', 'is_staff', 'is_banned', 'date_joined')
|
||||
search_fields = ('username', 'email', 'user_id', 'display_name')
|
||||
readonly_fields = ('user_id', 'date_joined', 'last_login')
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
('Personal info', {'fields': ('email', 'display_name', 'user_id')}),
|
||||
('Permissions', {'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
('Moderation', {'fields': ('is_banned', 'ban_reason', 'ban_date')}),
|
||||
('Preferences', {'fields': ('theme_preference', 'privacy_level')}),
|
||||
('Notifications', {'fields': ('email_notifications', 'push_notifications')}),
|
||||
)
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'profile_id', 'display_name', 'coaster_credits', 'dark_ride_credits')
|
||||
list_filter = ('user__role', 'user__is_active')
|
||||
search_fields = ('user__username', 'user__email', 'profile_id', 'display_name')
|
||||
readonly_fields = ('profile_id',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('user', 'profile_id', 'display_name')}),
|
||||
('Profile Info', {'fields': ('avatar', 'pronouns', 'bio')}),
|
||||
('Social Media', {'fields': ('twitter', 'instagram', 'youtube', 'discord')}),
|
||||
('Ride Statistics', {'fields': ('coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')}),
|
||||
)
|
||||
|
||||
@admin.register(EmailVerification)
|
||||
class EmailVerificationAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'token', 'created_at', 'last_sent')
|
||||
list_filter = ('created_at', 'last_sent')
|
||||
search_fields = ('user__username', 'user__email', 'token')
|
||||
readonly_fields = ('token', 'created_at', 'last_sent')
|
||||
23
backend/apps/core/forms/htmx_forms.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.views.generic.edit import FormView
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
|
||||
|
||||
class HTMXFormView(FormView):
|
||||
"""Base FormView that supports field-level validation endpoints for HTMX.
|
||||
|
||||
Subclasses can call `validate_field` to return JSON errors for a single field.
|
||||
"""
|
||||
|
||||
def validate_field(self, field_name):
|
||||
"""Return JSON with errors for a single field based on the current form."""
|
||||
form = self.get_form()
|
||||
field = form[field_name]
|
||||
form.is_valid() # populate errors
|
||||
errors = form.errors.get(field_name, [])
|
||||
return JsonResponse({"field": field_name, "errors": errors})
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# If HTMX field validation pattern: ?field=name
|
||||
if request.headers.get("HX-Request") == "true" and request.GET.get("validate_field"):
|
||||
return self.validate_field(request.GET.get("validate_field"))
|
||||
return super().post(request, *args, **kwargs)
|
||||
78
backend/apps/core/htmx_utils.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from functools import wraps
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
def htmx_partial(template_name):
|
||||
"""Decorator for view functions to render partials for HTMX requests.
|
||||
|
||||
If the request is an HTMX request and a partial template exists with
|
||||
the convention '<template_name>_partial.html', that template will be
|
||||
rendered. Otherwise the provided template_name is used.
|
||||
"""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped(request, *args, **kwargs):
|
||||
resp = view_func(request, *args, **kwargs)
|
||||
# If the view returned an HttpResponse, pass through
|
||||
if isinstance(resp, HttpResponse):
|
||||
return resp
|
||||
# Expecting a tuple (context, template_name) or (context,)
|
||||
context = {}
|
||||
tpl = template_name
|
||||
if isinstance(resp, tuple):
|
||||
if len(resp) >= 1:
|
||||
context = resp[0]
|
||||
if len(resp) >= 2 and resp[1]:
|
||||
tpl = resp[1]
|
||||
|
||||
# If HTMX, try partial template
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
partial = tpl.replace(".html", "_partial.html")
|
||||
try:
|
||||
html = render_to_string(partial, context, request=request)
|
||||
return HttpResponse(html)
|
||||
except Exception:
|
||||
# Fall back to full template
|
||||
html = render_to_string(tpl, context, request=request)
|
||||
return HttpResponse(html)
|
||||
|
||||
html = render_to_string(tpl, context, request=request)
|
||||
return HttpResponse(html)
|
||||
|
||||
return _wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def htmx_redirect(url):
|
||||
resp = HttpResponse("")
|
||||
resp["HX-Redirect"] = url
|
||||
return resp
|
||||
|
||||
|
||||
def htmx_trigger(name: str, payload: dict = None):
|
||||
resp = HttpResponse("")
|
||||
if payload is None:
|
||||
resp["HX-Trigger"] = name
|
||||
else:
|
||||
resp["HX-Trigger"] = JsonResponse({name: payload}).content.decode()
|
||||
return resp
|
||||
|
||||
|
||||
def htmx_refresh():
|
||||
resp = HttpResponse("")
|
||||
resp["HX-Refresh"] = "true"
|
||||
return resp
|
||||
|
||||
|
||||
def htmx_swap_oob(target_id: str, html: str):
|
||||
"""Return an out-of-band swap response by wrapping HTML and setting headers.
|
||||
|
||||
Note: For simple use cases this returns an HttpResponse containing the
|
||||
fragment; consumers should set `HX-Boost` headers when necessary.
|
||||
"""
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = f"oob:{target_id}"
|
||||
return resp
|
||||
22
backend/apps/core/middleware/htmx_error_middleware.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import logging
|
||||
from django.http import HttpResponseServerError
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HTMXErrorMiddleware:
|
||||
"""Catch exceptions on HTMX requests and return formatted error partials."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
try:
|
||||
return self.get_response(request)
|
||||
except Exception as exc:
|
||||
logger.exception("Error during request")
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
html = render_to_string("htmx/components/error_message.html", {"title": "Server error", "message": "An unexpected error occurred."})
|
||||
return HttpResponseServerError(html)
|
||||
raise
|
||||
@@ -1,19 +1,86 @@
|
||||
from typing import Optional
|
||||
from django.views.generic.list import MultipleObjectMixin
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django.template.loader import select_template
|
||||
|
||||
|
||||
"""HTMX mixins for views. Single canonical definitions for partial rendering and triggers."""
|
||||
|
||||
|
||||
class HTMXFilterableMixin(MultipleObjectMixin):
|
||||
"""
|
||||
A mixin that provides filtering capabilities for HTMX requests.
|
||||
"""
|
||||
"""Enhance list views to return partial templates for HTMX requests."""
|
||||
|
||||
filter_class = None
|
||||
htmx_partial_suffix = "_partial.html"
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
|
||||
return self.filterset.qs
|
||||
qs = super().get_queryset()
|
||||
if self.filter_class:
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=qs)
|
||||
return self.filterset.qs
|
||||
return qs
|
||||
|
||||
def get_template_names(self):
|
||||
names = super().get_template_names()
|
||||
if self.request.headers.get("HX-Request") == "true":
|
||||
partials = [t.replace(".html", self.htmx_partial_suffix) for t in names]
|
||||
try:
|
||||
select_template(partials)
|
||||
return partials
|
||||
except Exception:
|
||||
return names
|
||||
return names
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["filter"] = self.filterset
|
||||
return context
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
if hasattr(self, "filterset"):
|
||||
ctx["filter"] = self.filterset
|
||||
return ctx
|
||||
|
||||
|
||||
class HTMXFormMixin(FormMixin):
|
||||
"""FormMixin that returns partials and field-level errors for HTMX requests."""
|
||||
|
||||
htmx_success_trigger: Optional[str] = None
|
||||
|
||||
def form_invalid(self, form):
|
||||
if self.request.headers.get("HX-Request") == "true":
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
return super().form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
res = super().form_valid(form)
|
||||
if (
|
||||
self.request.headers.get("HX-Request") == "true"
|
||||
and self.htmx_success_trigger
|
||||
):
|
||||
res["HX-Trigger"] = self.htmx_success_trigger
|
||||
return res
|
||||
|
||||
|
||||
class HTMXInlineEditMixin(FormMixin):
|
||||
"""Support simple inline edit flows: GET returns form partial, POST returns updated fragment."""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class HTMXPaginationMixin:
|
||||
"""Pagination helper that supports hx-trigger based infinite scroll or standard pagination."""
|
||||
|
||||
page_size = 20
|
||||
|
||||
def get_paginate_by(self, queryset):
|
||||
return getattr(self, "paginate_by", self.page_size)
|
||||
|
||||
|
||||
class HTMXModalMixin(HTMXFormMixin):
|
||||
"""Mixin to help render forms inside modals and send close triggers on success."""
|
||||
|
||||
modal_close_trigger = "modal:close"
|
||||
|
||||
def form_valid(self, form):
|
||||
res = super().form_valid(form)
|
||||
if self.request.headers.get("HX-Request") == "true":
|
||||
res["HX-Trigger"] = self.modal_close_trigger
|
||||
return res
|
||||
|
||||
16
backend/apps/core/views/inline_edit.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.views.generic.edit import FormView
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
|
||||
class InlineEditView(FormView):
|
||||
"""Generic inline edit view: GET returns form fragment, POST returns updated fragment."""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.render_to_response(self.get_context_data())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
form = self.get_form()
|
||||
if form.is_valid():
|
||||
self.object = form.save()
|
||||
return self.render_to_response(self.get_context_data(object=self.object))
|
||||
return self.form_invalid(form)
|
||||
17
backend/apps/core/views/modal_views.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.views.generic.edit import FormView
|
||||
from django.http import HttpResponse
|
||||
|
||||
|
||||
class HTMXModalFormView(FormView):
|
||||
"""Render form inside a modal and respond with HTMX triggers on success."""
|
||||
|
||||
modal_template_name = "components/modals/modal_form.html"
|
||||
|
||||
def get_template_names(self):
|
||||
return [self.modal_template_name]
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
if self.request.headers.get("HX-Request") == "true":
|
||||
response["HX-Trigger"] = "modal:close"
|
||||
return response
|
||||
@@ -60,3 +60,32 @@ class SlugRedirectMixin(View):
|
||||
if not self.object:
|
||||
return {}
|
||||
return {self.slug_url_kwarg: getattr(self.object, "slug", "")}
|
||||
|
||||
|
||||
from django.views.generic import TemplateView
|
||||
from django.shortcuts import render
|
||||
|
||||
|
||||
class GlobalSearchView(TemplateView):
|
||||
"""Unified search view with HTMX support for debounced results and suggestions."""
|
||||
|
||||
template_name = "core/search/search.html"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
q = request.GET.get("q", "")
|
||||
results = []
|
||||
suggestions = []
|
||||
# Lightweight placeholder search: real implementation should query multiple models
|
||||
if q:
|
||||
# Return a small payload of mocked results to keep this scaffold safe
|
||||
results = [{"title": f"Result for {q}", "url": "#", "subtitle": "Park"}]
|
||||
suggestions = [{"text": q, "url": "#"}]
|
||||
|
||||
context = {"results": results, "suggestions": suggestions}
|
||||
|
||||
# If HTMX request, render dropdown partial
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
return render(request, "core/search/partials/search_dropdown.html", context)
|
||||
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
|
||||
@@ -54,6 +54,47 @@ urlpatterns = [
|
||||
ParkDistanceCalculatorView.as_view(),
|
||||
name="roadtrip_htmx_distance",
|
||||
),
|
||||
# Additional HTMX endpoints for client-driven route management
|
||||
path(
|
||||
"roadtrip/htmx/add-park/",
|
||||
views.htmx_add_park_to_trip,
|
||||
name="htmx_add_park_to_trip",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/remove-park/",
|
||||
views.htmx_remove_park_from_trip,
|
||||
name="htmx_remove_park_from_trip",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/reorder/",
|
||||
views.htmx_reorder_parks,
|
||||
name="htmx_reorder_parks",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/optimize/",
|
||||
views.htmx_optimize_route,
|
||||
name="htmx_optimize_route",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/calculate/",
|
||||
views.htmx_calculate_route,
|
||||
name="htmx_calculate_route",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/saved/",
|
||||
views.htmx_saved_trips,
|
||||
name="htmx_saved_trips",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/save/",
|
||||
views.htmx_save_trip,
|
||||
name="htmx_save_trip",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/clear/",
|
||||
views.htmx_clear_trip,
|
||||
name="htmx_clear_trip",
|
||||
),
|
||||
# Park detail and related views
|
||||
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
|
||||
path("<slug:slug>/edit/", views.ParkUpdateView.as_view(), name="park_update"),
|
||||
|
||||
@@ -31,6 +31,10 @@ from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||
import requests
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Optional, cast, Literal, Dict
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
import json
|
||||
|
||||
# Constants
|
||||
PARK_DETAIL_URL = "parks:park_detail"
|
||||
@@ -38,6 +42,9 @@ PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
|
||||
REQUIRED_FIELDS_ERROR = (
|
||||
"Please correct the errors below. Required fields are marked with an asterisk (*)."
|
||||
)
|
||||
TRIP_PARKS_TEMPLATE = "parks/partials/trip_parks_list.html"
|
||||
TRIP_SUMMARY_TEMPLATE = "parks/partials/trip_summary.html"
|
||||
SAVED_TRIPS_TEMPLATE = "parks/partials/saved_trips.html"
|
||||
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
|
||||
@@ -461,6 +468,250 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
return response
|
||||
|
||||
|
||||
# --------------------
|
||||
# HTMX roadtrip helpers
|
||||
# --------------------
|
||||
|
||||
|
||||
def htmx_saved_trips(request: HttpRequest) -> HttpResponse:
|
||||
"""Return a partial with the user's saved trips (stubbed)."""
|
||||
trips = []
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
from .models import Trip # type: ignore
|
||||
qs = Trip.objects.filter(owner=request.user).order_by("-created_at")
|
||||
trips = list(qs[:10])
|
||||
except Exception:
|
||||
trips = []
|
||||
return render(request, SAVED_TRIPS_TEMPLATE, {"trips": trips})
|
||||
|
||||
|
||||
def _get_session_trip(request: HttpRequest) -> list:
|
||||
raw = request.session.get("trip_parks", [])
|
||||
try:
|
||||
return [int(x) for x in raw]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_session_trip(request: HttpRequest, trip_list: list) -> None:
|
||||
request.session["trip_parks"] = [int(x) for x in trip_list]
|
||||
request.session.modified = True
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_add_park_to_trip(request: HttpRequest) -> HttpResponse:
|
||||
"""Add a park id to `request.session['trip_parks']` and return the full trip list partial."""
|
||||
park_id = request.POST.get("park_id")
|
||||
if not park_id:
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
park_id = payload.get("park_id")
|
||||
except Exception:
|
||||
park_id = None
|
||||
|
||||
if not park_id:
|
||||
return HttpResponse("", status=400)
|
||||
|
||||
try:
|
||||
pid = int(park_id)
|
||||
except Exception:
|
||||
return HttpResponse("", status=400)
|
||||
|
||||
trip = _get_session_trip(request)
|
||||
if pid not in trip:
|
||||
trip.append(pid)
|
||||
_save_session_trip(request, trip)
|
||||
|
||||
# Build ordered Park queryset preserving session order
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripUpdated": True})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_remove_park_from_trip(request: HttpRequest) -> HttpResponse:
|
||||
"""Remove a park id from `request.session['trip_parks']` and return the updated trip list partial."""
|
||||
park_id = request.POST.get("park_id")
|
||||
if not park_id:
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
park_id = payload.get("park_id")
|
||||
except Exception:
|
||||
park_id = None
|
||||
|
||||
if not park_id:
|
||||
return HttpResponse("", status=400)
|
||||
|
||||
try:
|
||||
pid = int(park_id)
|
||||
except Exception:
|
||||
return HttpResponse("", status=400)
|
||||
|
||||
trip = _get_session_trip(request)
|
||||
if pid in trip:
|
||||
trip = [t for t in trip if t != pid]
|
||||
_save_session_trip(request, trip)
|
||||
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripUpdated": True})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_reorder_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""Accept an ordered list of park ids and persist it to the session, returning the updated list partial."""
|
||||
order = []
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
order = payload.get("order", [])
|
||||
except Exception:
|
||||
order = request.POST.getlist("order[]")
|
||||
|
||||
# Normalize to ints
|
||||
clean_order = []
|
||||
for item in order:
|
||||
try:
|
||||
clean_order.append(int(item))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
_save_session_trip(request, clean_order)
|
||||
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripReordered": True})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_optimize_route(request: HttpRequest) -> HttpResponse:
|
||||
"""Compute a simple trip summary from session parks and return the summary partial."""
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Helper: haversine distance (miles)
|
||||
import math
|
||||
|
||||
def haversine_miles(lat1, lon1, lat2, lon2):
|
||||
# convert decimal degrees to radians
|
||||
rlat1, rlon1, rlat2, rlon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
||||
dlat = rlat2 - rlat1
|
||||
dlon = rlon2 - rlon1
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
|
||||
c = 2 * math.asin(min(1, math.sqrt(a)))
|
||||
miles = 3958.8 * c
|
||||
return miles
|
||||
|
||||
total_miles = 0.0
|
||||
waypoints = []
|
||||
for p in parks:
|
||||
loc = getattr(p, "location", None)
|
||||
lat = getattr(loc, "latitude", None) if loc else None
|
||||
lon = getattr(loc, "longitude", None) if loc else None
|
||||
if lat is not None and lon is not None:
|
||||
waypoints.append({"id": p.id, "name": p.name, "latitude": lat, "longitude": lon})
|
||||
|
||||
# sum straight-line distances between consecutive waypoints
|
||||
for i in range(len(waypoints) - 1):
|
||||
a = waypoints[i]
|
||||
b = waypoints[i + 1]
|
||||
try:
|
||||
total_miles += haversine_miles(a["latitude"], a["longitude"], b["latitude"], b["longitude"])
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Estimate drive time assuming average speed of 60 mph
|
||||
total_hours = total_miles / 60.0 if total_miles else 0.0
|
||||
|
||||
summary = {
|
||||
"total_distance": f"{int(round(total_miles))} mi",
|
||||
"total_time": f"{total_hours:.1f} hrs",
|
||||
"total_parks": len(parks),
|
||||
"total_rides": sum(getattr(p, "ride_count", 0) or 0 for p in parks),
|
||||
}
|
||||
|
||||
html = render_to_string(TRIP_SUMMARY_TEMPLATE, {"summary": summary}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
# Include waypoints payload in HX-Trigger so client can render route on the map
|
||||
resp["HX-Trigger"] = json.dumps({"tripOptimized": {"parks": waypoints}})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_calculate_route(request: HttpRequest) -> HttpResponse:
|
||||
"""Alias for optimize route for now — returns trip summary partial."""
|
||||
return htmx_optimize_route(request)
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_save_trip(request: HttpRequest) -> HttpResponse:
|
||||
"""Save the current session trip to a Trip model (if present) and return saved trips partial."""
|
||||
name = request.POST.get("name") or "My Trip"
|
||||
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
trips = []
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
from .models import Trip # type: ignore
|
||||
trip = Trip.objects.create(owner=request.user, name=name)
|
||||
# attempt to associate parks if the Trip model supports it
|
||||
try:
|
||||
trip.parks.set([p.id for p in parks])
|
||||
except Exception:
|
||||
pass
|
||||
trips = list(Trip.objects.filter(owner=request.user).order_by("-created_at")[:10])
|
||||
except Exception:
|
||||
trips = []
|
||||
|
||||
html = render_to_string(SAVED_TRIPS_TEMPLATE, {"trips": trips}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripSaved": True})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_clear_trip(request: HttpRequest) -> HttpResponse:
|
||||
"""Clear the current session trip and return an empty trip list partial."""
|
||||
_save_session_trip(request, [])
|
||||
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": []}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripCleared": True})
|
||||
return resp
|
||||
|
||||
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Park
|
||||
form_class = ParkForm
|
||||
@@ -517,7 +768,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
park_location, _ = ParkLocation.objects.get_or_create(
|
||||
park=self.object,
|
||||
defaults={
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
// Reduced Alpine components: keep only pure client-side UI state
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('themeToggle', () => ({
|
||||
theme: localStorage.getItem('theme') || 'system',
|
||||
init() { this.updateTheme(); },
|
||||
toggle() {
|
||||
this.theme = this.theme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', this.theme);
|
||||
this.updateTheme();
|
||||
},
|
||||
updateTheme() {
|
||||
if (this.theme === 'dark') document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}));
|
||||
|
||||
Alpine.data('mobileMenu', () => ({
|
||||
open: false,
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
document.body.style.overflow = this.open ? 'hidden' : '';
|
||||
}
|
||||
}));
|
||||
|
||||
Alpine.data('dropdown', () => ({
|
||||
open: false,
|
||||
toggle() { this.open = !this.open; }
|
||||
}));
|
||||
});
|
||||
/**
|
||||
* Alpine.js Components for ThrillWiki
|
||||
* Enhanced components matching React frontend functionality
|
||||
@@ -367,202 +396,7 @@ Alpine.data('toast', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// Enhanced Authentication Modal Component
|
||||
Alpine.data('authModal', (defaultMode = 'login') => ({
|
||||
open: false,
|
||||
mode: defaultMode, // 'login' or 'register'
|
||||
showPassword: false,
|
||||
socialProviders: [],
|
||||
socialLoading: true,
|
||||
|
||||
// Login form data
|
||||
loginForm: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
loginLoading: false,
|
||||
loginError: '',
|
||||
|
||||
// Register form data
|
||||
registerForm: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password1: '',
|
||||
password2: ''
|
||||
},
|
||||
registerLoading: false,
|
||||
registerError: '',
|
||||
|
||||
init() {
|
||||
this.fetchSocialProviders();
|
||||
|
||||
// Listen for auth modal events
|
||||
this.$watch('open', (value) => {
|
||||
if (value) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
this.resetForms();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async fetchSocialProviders() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/social-providers/');
|
||||
const data = await response.json();
|
||||
this.socialProviders = data.available_providers || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch social providers:', error);
|
||||
this.socialProviders = [];
|
||||
} finally {
|
||||
this.socialLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
show(mode = 'login') {
|
||||
this.mode = mode;
|
||||
this.open = true;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
},
|
||||
|
||||
switchToLogin() {
|
||||
this.mode = 'login';
|
||||
this.resetForms();
|
||||
},
|
||||
|
||||
switchToRegister() {
|
||||
this.mode = 'register';
|
||||
this.resetForms();
|
||||
},
|
||||
|
||||
resetForms() {
|
||||
this.loginForm = { username: '', password: '' };
|
||||
this.registerForm = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password1: '',
|
||||
password2: ''
|
||||
};
|
||||
this.loginError = '';
|
||||
this.registerError = '';
|
||||
this.showPassword = false;
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
if (!this.loginForm.username || !this.loginForm.password) {
|
||||
this.loginError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/accounts/login/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': this.getCSRFToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
login: this.loginForm.username,
|
||||
password: this.loginForm.password
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Login successful - reload page to update auth state
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
this.loginError = data.message || 'Login failed. Please check your credentials.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
this.loginError = 'An error occurred. Please try again.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async handleRegister() {
|
||||
if (!this.registerForm.first_name || !this.registerForm.last_name ||
|
||||
!this.registerForm.email || !this.registerForm.username ||
|
||||
!this.registerForm.password1 || !this.registerForm.password2) {
|
||||
this.registerError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.registerForm.password1 !== this.registerForm.password2) {
|
||||
this.registerError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerLoading = true;
|
||||
this.registerError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/accounts/signup/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': this.getCSRFToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
first_name: this.registerForm.first_name,
|
||||
last_name: this.registerForm.last_name,
|
||||
email: this.registerForm.email,
|
||||
username: this.registerForm.username,
|
||||
password1: this.registerForm.password1,
|
||||
password2: this.registerForm.password2
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Registration successful
|
||||
this.close();
|
||||
// Show success message or redirect
|
||||
Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
this.registerError = data.message || 'Registration failed. Please try again.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
this.registerError = 'An error occurred. Please try again.';
|
||||
} finally {
|
||||
this.registerLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleSocialLogin(providerId) {
|
||||
const provider = this.socialProviders.find(p => p.id === providerId);
|
||||
if (!provider) {
|
||||
Alpine.store('toast').error(`Social provider ${providerId} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to social auth URL
|
||||
window.location.href = provider.auth_url;
|
||||
},
|
||||
|
||||
getCSRFToken() {
|
||||
const token = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
|
||||
document.querySelector('meta[name=csrf-token]')?.getAttribute('content') ||
|
||||
document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
|
||||
return token || '';
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
// Enhanced Toast Component with Better UX
|
||||
Alpine.data('toast', () => ({
|
||||
|
||||
@@ -1,774 +1,209 @@
|
||||
/**
|
||||
* ThrillWiki Road Trip Planner - Multi-park Route Planning
|
||||
*
|
||||
* This module provides road trip planning functionality with multi-park selection,
|
||||
* route visualization, distance calculations, and export capabilities
|
||||
*/
|
||||
/* Minimal Roadtrip JS helpers for HTMX-driven planner
|
||||
- Initializes map helpers when Leaflet is available
|
||||
- Exposes `RoadtripMap` global with basic marker helpers
|
||||
- Heavy client-side trip logic is intentionally moved to HTMX endpoints
|
||||
*/
|
||||
|
||||
class RoadTripPlanner {
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
mapInstance: null,
|
||||
maxParks: 20,
|
||||
enableOptimization: true,
|
||||
enableExport: true,
|
||||
apiEndpoints: {
|
||||
parks: '/api/parks/',
|
||||
route: '/api/roadtrip/route/',
|
||||
optimize: '/api/roadtrip/optimize/',
|
||||
export: '/api/roadtrip/export/'
|
||||
},
|
||||
routeOptions: {
|
||||
color: '#3B82F6',
|
||||
weight: 4,
|
||||
opacity: 0.8
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
this.container = null;
|
||||
this.mapInstance = null;
|
||||
this.selectedParks = [];
|
||||
this.routeLayer = null;
|
||||
this.parkMarkers = new Map();
|
||||
this.routePolyline = null;
|
||||
this.routeData = null;
|
||||
|
||||
this.init();
|
||||
class RoadtripMap {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.markers = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the road trip planner
|
||||
*/
|
||||
init() {
|
||||
this.container = document.getElementById(this.containerId);
|
||||
if (!this.container) {
|
||||
console.error(`Road trip container with ID '${this.containerId}' not found`);
|
||||
return;
|
||||
|
||||
init(containerId, opts = {}) {
|
||||
if (typeof L === 'undefined') return;
|
||||
try {
|
||||
this.map = L.map(containerId).setView([51.505, -0.09], 5);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(this.map);
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize map', e);
|
||||
}
|
||||
|
||||
this.setupUI();
|
||||
this.bindEvents();
|
||||
|
||||
// Connect to map instance if provided
|
||||
if (this.options.mapInstance) {
|
||||
this.connectToMap(this.options.mapInstance);
|
||||
}
|
||||
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the UI components
|
||||
*/
|
||||
setupUI() {
|
||||
const html = `
|
||||
<div class="roadtrip-planner">
|
||||
<div class="roadtrip-header">
|
||||
<h3 class="roadtrip-title">
|
||||
<i class="fas fa-route"></i>
|
||||
Road Trip Planner
|
||||
</h3>
|
||||
<div class="roadtrip-controls">
|
||||
<button id="optimize-route" class="btn btn-secondary btn-sm" disabled>
|
||||
<i class="fas fa-magic"></i> Optimize Route
|
||||
</button>
|
||||
<button id="clear-route" class="btn btn-outline btn-sm" disabled>
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roadtrip-content">
|
||||
<div class="park-selection">
|
||||
<div class="search-parks">
|
||||
<input type="text" id="park-search"
|
||||
placeholder="Search parks to add..."
|
||||
class="form-input">
|
||||
<div id="park-search-results" class="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-parks">
|
||||
<h4 class="section-title">Your Route (<span id="park-count">0</span>/${this.options.maxParks})</h4>
|
||||
<div id="parks-list" class="parks-list sortable">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-map-marked-alt"></i>
|
||||
<p>Search and select parks to build your road trip route</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="route-summary" id="route-summary" style="display: none;">
|
||||
<h4 class="section-title">Trip Summary</h4>
|
||||
<div class="summary-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Total Distance:</span>
|
||||
<span id="total-distance" class="stat-value">-</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Driving Time:</span>
|
||||
<span id="total-time" class="stat-value">-</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Parks:</span>
|
||||
<span id="total-parks" class="stat-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-options">
|
||||
<button id="export-gpx" class="btn btn-outline btn-sm">
|
||||
<i class="fas fa-download"></i> Export GPX
|
||||
</button>
|
||||
<button id="export-kml" class="btn btn-outline btn-sm">
|
||||
<i class="fas fa-download"></i> Export KML
|
||||
</button>
|
||||
<button id="share-route" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-share"></i> Share Route
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = html;
|
||||
|
||||
addMarker(park) {
|
||||
if (!this.map || !park || !park.latitude || !park.longitude) return;
|
||||
const id = park.id;
|
||||
if (this.markers[id]) return;
|
||||
const m = L.marker([park.latitude, park.longitude]).addTo(this.map).bindPopup(park.name);
|
||||
this.markers[id] = m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event handlers
|
||||
*/
|
||||
bindEvents() {
|
||||
// Park search
|
||||
const searchInput = document.getElementById('park-search');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
this.searchParks(e.target.value);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
removeMarker(parkId) {
|
||||
const m = this.markers[parkId];
|
||||
if (m && this.map) {
|
||||
this.map.removeLayer(m);
|
||||
delete this.markers[parkId];
|
||||
}
|
||||
|
||||
// Route controls
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
if (optimizeBtn) {
|
||||
optimizeBtn.addEventListener('click', () => this.optimizeRoute());
|
||||
}
|
||||
|
||||
const clearBtn = document.getElementById('clear-route');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearRoute());
|
||||
}
|
||||
|
||||
// Export buttons
|
||||
const exportGpxBtn = document.getElementById('export-gpx');
|
||||
if (exportGpxBtn) {
|
||||
exportGpxBtn.addEventListener('click', () => this.exportRoute('gpx'));
|
||||
}
|
||||
|
||||
const exportKmlBtn = document.getElementById('export-kml');
|
||||
if (exportKmlBtn) {
|
||||
exportKmlBtn.addEventListener('click', () => this.exportRoute('kml'));
|
||||
}
|
||||
|
||||
const shareBtn = document.getElementById('share-route');
|
||||
if (shareBtn) {
|
||||
shareBtn.addEventListener('click', () => this.shareRoute());
|
||||
}
|
||||
|
||||
// Make parks list sortable
|
||||
this.initializeSortable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize drag-and-drop sorting for parks list
|
||||
*/
|
||||
initializeSortable() {
|
||||
const parksList = document.getElementById('parks-list');
|
||||
if (!parksList) return;
|
||||
|
||||
// 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
|
||||
@@ -7,361 +7,25 @@ Matches React frontend AuthDialog functionality with modal-based auth
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
|
||||
<!-- Auth Modal Component -->
|
||||
<div
|
||||
x-data="authModal()"
|
||||
x-show="open"
|
||||
x-cloak
|
||||
x-init="window.authModal = $data"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@keydown.escape.window="close()"
|
||||
>
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition-opacity ease-linear duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity ease-linear duration-300"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-background/80 backdrop-blur-sm"
|
||||
@click="close()"
|
||||
></div>
|
||||
<!-- HTMX-driven Auth Modal Container -->
|
||||
{# This modal no longer manages form submission client-side. Forms are fetched
|
||||
and submitted via HTMX using the account views endpoints (CustomLoginView/CustomSignupView). #}
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="relative w-full max-w-md mx-4 bg-background border rounded-lg shadow-lg"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="close()"
|
||||
class="absolute top-4 right-4 p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<div id="auth-modal" class="fixed inset-0 z-50 hidden items-center justify-center" role="dialog" aria-modal="true" tabindex="-1" hx-on:keydown="if(event.key==='Escape'){ document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden'); }">
|
||||
<div id="auth-modal-overlay" class="fixed inset-0 bg-background/80 backdrop-blur-sm" onclick="document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden');"></div>
|
||||
|
||||
<div id="auth-modal-content" class="relative w-full max-w-md mx-4 bg-background border rounded-lg shadow-lg" role="dialog" aria-modal="true">
|
||||
<button type="button" class="absolute top-4 right-4 p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors auth-close" onclick="document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden');">
|
||||
<i class="fas fa-times w-4 h-4"></i>
|
||||
</button>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div x-show="mode === 'login'" class="p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
|
||||
Sign In
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
Enter your credentials to access your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Social Login Buttons -->
|
||||
<div x-show="socialProviders.length > 0" class="mb-6">
|
||||
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
|
||||
<template x-for="provider in socialProviders" :key="provider.id">
|
||||
<button
|
||||
@click="handleSocialLogin(provider.id)"
|
||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
|
||||
:class="{
|
||||
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
|
||||
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
|
||||
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="mr-2 w-4 h-4"
|
||||
:class="{
|
||||
'fab fa-google': provider.id === 'google',
|
||||
'fab fa-discord': provider.id === 'discord'
|
||||
}"
|
||||
></i>
|
||||
<span x-text="provider.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="socialLoading" class="grid grid-cols-2 gap-4">
|
||||
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
|
||||
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-muted"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form
|
||||
@submit.prevent="handleLogin()"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<label for="login-username" class="text-sm font-medium">
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
x-model="loginForm.username"
|
||||
placeholder="Enter your email or username"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="login-password" class="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
x-model="loginForm.password"
|
||||
placeholder="Enter your password"
|
||||
class="input w-full pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href="{% url 'account_reset_password' %}"
|
||||
class="text-sm text-primary hover:text-primary/80 underline-offset-4 hover:underline font-medium"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div x-show="loginError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<span x-text="loginError"></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loginLoading"
|
||||
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
|
||||
>
|
||||
<span x-show="!loginLoading">Sign In</span>
|
||||
<span x-show="loginLoading" class="flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
Signing in...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Switch to Register -->
|
||||
<div class="text-center text-sm text-muted-foreground mt-6">
|
||||
Don't have an account?
|
||||
<button
|
||||
@click="switchToRegister()"
|
||||
class="text-primary hover:underline font-medium ml-1"
|
||||
type="button"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<div x-show="mode === 'register'" class="p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
|
||||
Create Account
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
Join ThrillWiki to start exploring theme parks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Social Registration Buttons -->
|
||||
<div x-show="socialProviders.length > 0" class="mb-6">
|
||||
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
|
||||
<template x-for="provider in socialProviders" :key="provider.id">
|
||||
<button
|
||||
@click="handleSocialLogin(provider.id)"
|
||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
|
||||
:class="{
|
||||
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
|
||||
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
|
||||
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="mr-2 w-4 h-4"
|
||||
:class="{
|
||||
'fab fa-google': provider.id === 'google',
|
||||
'fab fa-discord': provider.id === 'discord'
|
||||
}"
|
||||
></i>
|
||||
<span x-text="provider.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-muted"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-background px-2 text-muted-foreground">
|
||||
Or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<form
|
||||
@submit.prevent="handleRegister()"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="register-first-name" class="text-sm font-medium">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
id="register-first-name"
|
||||
type="text"
|
||||
x-model="registerForm.first_name"
|
||||
placeholder="First name"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="register-last-name" class="text-sm font-medium">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
id="register-last-name"
|
||||
type="text"
|
||||
x-model="registerForm.last_name"
|
||||
placeholder="Last name"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-email" class="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="register-email"
|
||||
type="email"
|
||||
x-model="registerForm.email"
|
||||
placeholder="Enter your email"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-username" class="text-sm font-medium">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="register-username"
|
||||
type="text"
|
||||
x-model="registerForm.username"
|
||||
placeholder="Choose a username"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-password" class="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="register-password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
x-model="registerForm.password1"
|
||||
placeholder="Create a password"
|
||||
class="input w-full pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-password2" class="text-sm font-medium">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="register-password2"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
x-model="registerForm.password2"
|
||||
placeholder="Confirm your password"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div x-show="registerError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<span x-text="registerError"></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="registerLoading"
|
||||
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
|
||||
>
|
||||
<span x-show="!registerLoading">Create Account</span>
|
||||
<span x-show="registerLoading" class="flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
Creating account...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Switch to Login -->
|
||||
<div class="text-center text-sm text-muted-foreground mt-6">
|
||||
Already have an account?
|
||||
<button
|
||||
@click="switchToLogin()"
|
||||
class="text-primary hover:underline font-medium ml-1"
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content will be loaded here via HTMX -->
|
||||
<div id="auth-modal-body" hx-swap-oob="true" hx-on:htmx:afterSwap="(function(){ var el=document.querySelector('#auth-modal-body input, #auth-modal-body button'); if(el){ el.focus(); } })()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Example triggers (elsewhere in the app you can use hx-get to load the desired form into #auth-modal-body):
|
||||
<button hx-get="{% url 'account_login' %}" hx-target="#auth-modal-body" hx-swap="innerHTML" onclick="document.getElementById('auth-modal').classList.remove('hidden')">Sign in</button>
|
||||
<button hx-get="{% url 'account_signup' %}" hx-target="#auth-modal-body" hx-swap="innerHTML" onclick="document.getElementById('auth-modal').classList.remove('hidden')">Sign up</button>
|
||||
The login/signup views already return partials for HTMX requests (see `CustomLoginView` / `CustomSignupView`).
|
||||
#}
|
||||
|
||||
5
backend/templates/components/filters/active_filters.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="active-filters">
|
||||
{% for f in active %}
|
||||
<span class="active-filter">{{ f.label }} <button hx-get="{{ f.remove_url }}">×</button></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" name="{{ name }}" value="{{ item.value }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar" />
|
||||
<span>{{ item.label }} <small>({{ item.count }})</small></span>
|
||||
</label>
|
||||
8
backend/templates/components/filters/filter_group.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<section class="filter-group" aria-expanded="true">
|
||||
<h4>{{ group.title }}</h4>
|
||||
<div class="filter-items">
|
||||
{% for item in group.items %}
|
||||
{% include "components/filters/filter_checkbox.html" with item=item %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
4
backend/templates/components/filters/filter_range.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="filter-range">
|
||||
<label>{{ label }}</label>
|
||||
<input type="range" name="{{ name }}" min="{{ min }}" max="{{ max }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar" />
|
||||
</div>
|
||||
8
backend/templates/components/filters/filter_select.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<div class="filter-select">
|
||||
<label for="{{ name }}">{{ label }}</label>
|
||||
<select id="{{ name }}" name="{{ name }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar">
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.value }}">{{ opt.label }} ({{ opt.count }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
6
backend/templates/components/filters/filter_sidebar.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<aside class="filter-sidebar" id="filter-sidebar">
|
||||
{% for group in groups %}
|
||||
{% include "components/filters/filter_group.html" with group=group %}
|
||||
{% endfor %}
|
||||
<div class="applied-filters">{% include "components/filters/active_filters.html" %}</div>
|
||||
</aside>
|
||||
7
backend/templates/components/inline_edit/edit_form.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<form hx-post="{{ action }}" hx-target="closest .editable-field" hx-swap="outerHTML">
|
||||
{% for field in form %}
|
||||
{% include "forms/partials/form_field.html" with field=field %}
|
||||
{% endfor %}
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
</form>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="editable-field editable-field-{{ name }}">
|
||||
<div class="field-display">{% include "components/inline_edit/field_display.html" %}</div>
|
||||
<div class="field-edit" hx-swap-oob="true"></div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="field-display-value">{{ value }}</div>
|
||||
@@ -1,3 +1,4 @@
|
||||
{# Global search removed duplicate header; primary header below handles search #}
|
||||
{% comment %}
|
||||
Enhanced Header Component - Matches React Frontend Design
|
||||
Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
@@ -133,32 +134,29 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
|
||||
<!-- Desktop Right Side -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<!-- Enhanced Search -->
|
||||
<div class="relative" x-data="searchComponent()">
|
||||
<!-- Enhanced Search (HTMX-driven) -->
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"></i>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search parks, rides..."
|
||||
class="w-[300px] pl-10 pr-20 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
hx-get="{% url 'search:search' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#search-results"
|
||||
hx-include="this"
|
||||
hx-indicator=".htmx-loading-indicator"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
/>
|
||||
{% include 'components/ui/button.html' with variant='default' size='sm' text='Search' class='absolute right-1 top-1/2 transform -translate-y-1/2' %}
|
||||
</div>
|
||||
|
||||
<!-- Search Results Dropdown -->
|
||||
|
||||
<!-- Search Results Dropdown: always present and controlled by HTMX swaps -->
|
||||
<div
|
||||
id="search-results"
|
||||
x-show="results.length > 0"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
|
||||
aria-live="polite"
|
||||
>
|
||||
<!-- Search results will be populated by HTMX -->
|
||||
</div>
|
||||
@@ -239,13 +237,19 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
{% else %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="window.authModal.show('login')"
|
||||
hx-get="{% url 'account_login' %}"
|
||||
hx-target="#auth-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('auth-modal').classList.remove('hidden'); document.body.classList.add('overflow-hidden');"
|
||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 rounded-md px-3"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
@click="window.authModal.show('register')"
|
||||
hx-get="{% url 'account_signup' %}"
|
||||
hx-target="#auth-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('auth-modal').classList.remove('hidden'); document.body.classList.add('overflow-hidden');"
|
||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 rounded-md px-3"
|
||||
>
|
||||
Sign Up
|
||||
|
||||
5
backend/templates/components/modals/modal_base.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div id="modal-container" class="modal" role="dialog" aria-modal="true" tabindex="-1">
|
||||
<div class="modal-content">
|
||||
{% block modal_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
5
backend/templates/components/modals/modal_confirm.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends "components/modals/modal_base.html" %}
|
||||
|
||||
{% block modal_content %}
|
||||
{% include "htmx/components/confirm_dialog.html" %}
|
||||
{% endblock %}
|
||||
10
backend/templates/components/modals/modal_form.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "components/modals/modal_base.html" %}
|
||||
|
||||
{% block modal_content %}
|
||||
<form hx-post="{{ action }}" hx-target="#modal-container" hx-swap="outerHTML">
|
||||
{% for field in form %}
|
||||
{% include "forms/partials/form_field.html" with field=field %}
|
||||
{% endfor %}
|
||||
{% include "forms/partials/form_actions.html" %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
3
backend/templates/components/modals/modal_loading.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="modal-loading">
|
||||
{% include "htmx/components/loading_indicator.html" %}
|
||||
</div>
|
||||
10
backend/templates/core/search/partials/search_dropdown.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<div id="search-dropdown" class="search-dropdown">
|
||||
{% include "core/search/partials/search_suggestions.html" %}
|
||||
<div id="search-results">
|
||||
{% for item in results %}
|
||||
{% include "core/search/partials/search_result_item.html" with item=item %}
|
||||
{% empty %}
|
||||
{% include "core/search/partials/search_empty.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
1
backend/templates/core/search/partials/search_empty.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="search-empty">No results found.</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="search-result-item">
|
||||
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||
<div class="muted">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<ul class="search-suggestions">
|
||||
{% for suggestion in suggestions %}
|
||||
<li hx-get="{{ suggestion.url }}" hx-swap="#search-results">{{ suggestion.text }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
9
backend/templates/core/search/search.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "base/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Search</h1>
|
||||
<form hx-get="/search/" hx-trigger="input changed delay:300ms" hx-target="#search-dropdown">
|
||||
<input name="q" placeholder="Search..." />
|
||||
</form>
|
||||
<div id="search-dropdown"></div>
|
||||
{% endblock %}
|
||||
7
backend/templates/forms/partials/field_error.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% if errors %}
|
||||
<ul class="field-errors">
|
||||
{% for e in errors %}
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
1
backend/templates/forms/partials/field_success.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="field-success" aria-hidden="true">✓</div>
|
||||
4
backend/templates/forms/partials/form_actions.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
<button type="button" class="btn-secondary" hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
</div>
|
||||
5
backend/templates/forms/partials/form_field.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="form-field" data-field-name="{{ field.name }}">
|
||||
<label for="id_{{ field.name }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
<div class="field-feedback" aria-live="polite">{% include "forms/partials/field_error.html" %}</div>
|
||||
</div>
|
||||
9
backend/templates/htmx/components/confirm_dialog.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="htmx-confirm" role="dialog" aria-modal="true">
|
||||
<div class="confirm-body">
|
||||
<p>{{ message|default:"Are you sure?" }}</p>
|
||||
<div class="confirm-actions">
|
||||
<button hx-post="{{ confirm_url }}" hx-vals='{"confirm": true}'>Confirm</button>
|
||||
<button hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
4
backend/templates/htmx/components/error_message.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="htmx-error" role="alert" aria-live="assertive">
|
||||
<strong>{{ title|default:"Error" }}</strong>
|
||||
<p>{{ message|default:"An error occurred. Please try again." }}</p>
|
||||
</div>
|
||||
4
backend/templates/htmx/components/filter_badge.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<span class="filter-badge" role="status">
|
||||
{{ label }}
|
||||
<button hx-get="{{ remove_url }}" hx-swap="outerHTML">×</button>
|
||||
</span>
|
||||
4
backend/templates/htmx/components/inline_edit_field.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="inline-edit-field" data-editable-name="{{ name }}">
|
||||
<div class="display">{{ value }}</div>
|
||||
<button hx-get="{{ edit_url }}" hx-target="closest .inline-edit-field" hx-swap="outerHTML">Edit</button>
|
||||
</div>
|
||||
3
backend/templates/htmx/components/loading_indicator.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="htmx-loading-indicator" aria-hidden="true">
|
||||
<div class="spinner">Loading…</div>
|
||||
</div>
|
||||
9
backend/templates/htmx/components/pagination.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<nav class="htmx-pagination" role="navigation" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<button hx-get="{{ request.path }}?page={{ page_obj.previous_page_number }}" hx-swap="#results">Previous</button>
|
||||
{% endif %}
|
||||
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
{% if page_obj.has_next %}
|
||||
<button hx-get="{{ request.path }}?page={{ page_obj.next_page_number }}" hx-swap="#results">Next</button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
3
backend/templates/htmx/components/success_toast.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="htmx-success" role="status" aria-live="polite">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
19
backend/templates/parks/partials/park_list_item.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% for park in parks %}
|
||||
<div class="park-search-item p-2 border-b border-gray-100 flex items-center justify-between" data-park-id="{{ park.id }}">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ park.name }}</div>
|
||||
{% if park.location %}
|
||||
<div class="text-xs text-gray-500">{{ park.location.city }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<button class="px-2 py-1 text-sm bg-blue-600 text-white rounded"
|
||||
hx-post="{% url 'parks:htmx_add_park_to_trip' %}"
|
||||
hx-vals='{"park_id": "{{ park.id }}"}'
|
||||
hx-target="#trip-parks"
|
||||
hx-swap="afterbegin">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
21
backend/templates/parks/partials/saved_trips.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="saved-trips-list">
|
||||
{% if trips %}
|
||||
<ul class="space-y-2">
|
||||
{% for trip in trips %}
|
||||
<li class="p-2 bg-white rounded-md shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium">{{ trip.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ trip.created_at }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'parks:roadtrip_detail' trip.id %}" class="text-blue-600">View</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-gray-500">No saved trips yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
16
backend/templates/parks/partials/trip_park_item.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="trip-park-item draggable-item flex items-center justify-between p-2 bg-gray-50 rounded-md" data-park-id="{{ park.id }}">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-sm font-medium text-gray-900">{{ park.name }}</div>
|
||||
{% if park.location %}
|
||||
<div class="text-xs text-gray-500">{{ park.location.city }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button class="px-2 py-1 text-sm text-red-600 hover:text-red-800"
|
||||
hx-post="{% url 'parks:htmx_remove_park_from_trip' %}"
|
||||
hx-vals='{"park_id": "{{ park.id }}"}'
|
||||
hx-swap="delete">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
34
backend/templates/parks/partials/trip_parks_list.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<div id="trip-parks" class="space-y-2">
|
||||
{% if trip_parks %}
|
||||
{% for park in trip_parks %}
|
||||
{% include 'parks/partials/trip_park_item.html' with park=park %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div id="empty-trip" class="text-center py-8 text-gray-500">
|
||||
<i class="fas fa-route text-3xl mb-3"></i>
|
||||
<p>Add parks to start planning your trip</p>
|
||||
<p class="text-sm mt-1">Search above or click parks on the map</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('htmx:load', function () {
|
||||
const el = document.getElementById('trip-parks');
|
||||
if (!el) return;
|
||||
if (el.dataset.sortableInit) return;
|
||||
if (typeof Sortable === 'undefined' || typeof htmx === 'undefined') return;
|
||||
el.dataset.sortableInit = '1';
|
||||
const sorter = new Sortable(el, {
|
||||
animation: 150,
|
||||
handle: '.draggable-item',
|
||||
onEnd: function () {
|
||||
const order = Array.from(el.querySelectorAll('[data-park-id]')).map(function (n) {
|
||||
return n.dataset.parkId;
|
||||
});
|
||||
htmx.ajax('POST', '{% url "parks:htmx_reorder_parks" %}', { values: { 'order[]': order } });
|
||||
},
|
||||
});
|
||||
el._tripSortable = sorter;
|
||||
});
|
||||
</script>
|
||||
21
backend/templates/parks/partials/trip_summary.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div id="trip-summary" class="trip-summary-card">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900">Trip Summary</h3>
|
||||
<div class="trip-stats grid grid-cols-2 gap-4">
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-distance">{{ summary.total_distance }}</div>
|
||||
<div class="trip-stat-label">Total Miles</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-time">{{ summary.total_time }}</div>
|
||||
<div class="trip-stat-label">Drive Time</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-parks">{{ summary.total_parks }}</div>
|
||||
<div class="trip-stat-label">Parks</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-rides">{{ summary.total_rides }}</div>
|
||||
<div class="trip-stat-label">Total Rides</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +156,7 @@
|
||||
<input type="text" id="park-search"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search parks by name or location..."
|
||||
hx-get="{% url 'parks:htmx_search_parks' %}"
|
||||
hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#park-search-results"
|
||||
hx-indicator="#search-loading">
|
||||
@@ -166,18 +166,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
|
||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<!-- Search results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trip Itinerary -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
||||
<button id="clear-trip"
|
||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
onclick="tripPlanner.clearTrip()">
|
||||
hx-post="{% url 'parks:htmx_clear_trip' %}"
|
||||
hx-target="#trip-parks"
|
||||
hx-swap="innerHTML">
|
||||
<i class="mr-1 fas fa-trash"></i>Clear All
|
||||
</button>
|
||||
</div>
|
||||
@@ -190,15 +192,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="mt-4 space-y-2">
|
||||
<button id="optimize-route"
|
||||
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.optimizeRoute()" disabled>
|
||||
hx-post="{% url 'parks:htmx_optimize_route' %}"
|
||||
hx-target="#trip-summary"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#trip-summary-loading">
|
||||
<i class="mr-2 fas fa-route"></i>Optimize Route
|
||||
</button>
|
||||
<button id="calculate-route"
|
||||
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.calculateRoute()" disabled>
|
||||
hx-post="{% url 'parks:htmx_calculate_route' %}"
|
||||
hx-target="#trip-summary"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#trip-summary-loading">
|
||||
<i class="mr-2 fas fa-map"></i>Calculate Route
|
||||
</button>
|
||||
</div>
|
||||
@@ -230,7 +238,10 @@
|
||||
<div class="mt-4">
|
||||
<button id="save-trip"
|
||||
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
onclick="tripPlanner.saveTrip()">
|
||||
hx-post="{% url 'parks:htmx_save_trip' %}"
|
||||
hx-target="#saved-trips"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#trips-loading">
|
||||
<i class="mr-2 fas fa-save"></i>Save Trip
|
||||
</button>
|
||||
</div>
|
||||
@@ -245,12 +256,12 @@
|
||||
<div class="flex gap-2">
|
||||
<button id="fit-route"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.fitRoute()">
|
||||
onclick="(window.roadTripPlanner||{}).fitRoute()">
|
||||
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
||||
</button>
|
||||
<button id="toggle-parks"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.toggleAllParks()">
|
||||
onclick="(window.roadTripPlanner||{}).toggleAllParks()">
|
||||
<i class="mr-1 fas fa-eye"></i>Show All Parks
|
||||
</button>
|
||||
</div>
|
||||
@@ -306,483 +317,12 @@
|
||||
<!-- Sortable JS for drag & drop -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<script src="{% static 'js/roadtrip.js' %}"></script>
|
||||
<script>
|
||||
// Road Trip Planner class
|
||||
class TripPlanner {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.tripParks = [];
|
||||
this.allParks = [];
|
||||
this.parkMarkers = {};
|
||||
this.routeControl = null;
|
||||
this.showingAllParks = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initMap();
|
||||
this.loadAllParks();
|
||||
this.initDragDrop();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
initMap() {
|
||||
// Initialize the map
|
||||
this.map = L.map('map-container', {
|
||||
center: [39.8283, -98.5795],
|
||||
zoom: 4,
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
async loadAllParks() {
|
||||
try {
|
||||
const response = await fetch('{{ map_api_urls.locations }}?types=park&limit=1000');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.data.locations) {
|
||||
this.allParks = data.data.locations;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load parks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initDragDrop() {
|
||||
// Make trip parks sortable
|
||||
new Sortable(document.getElementById('trip-parks'), {
|
||||
animation: 150,
|
||||
ghostClass: 'drag-over',
|
||||
onEnd: (evt) => {
|
||||
this.reorderTripParks(evt.oldIndex, evt.newIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Handle park search results
|
||||
document.addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.target.id === 'park-search-results') {
|
||||
this.handleSearchResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSearchResults() {
|
||||
const results = document.getElementById('park-search-results');
|
||||
if (results.children.length > 0) {
|
||||
results.classList.remove('hidden');
|
||||
} else {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
addParkToTrip(parkData) {
|
||||
// Check if park already in trip
|
||||
if (this.tripParks.find(p => p.id === parkData.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tripParks.push(parkData);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
// Hide search results
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
document.getElementById('park-search').value = '';
|
||||
}
|
||||
|
||||
removeParkFromTrip(parkId) {
|
||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateTripDisplay() {
|
||||
const container = document.getElementById('trip-parks');
|
||||
const emptyState = document.getElementById('empty-trip');
|
||||
|
||||
if (this.tripParks.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
// Clear existing parks (except empty state)
|
||||
Array.from(container.children).forEach(child => {
|
||||
if (child.id !== 'empty-trip') {
|
||||
child.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Add trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const parkElement = this.createTripParkElement(park, index);
|
||||
container.appendChild(parkElement);
|
||||
});
|
||||
}
|
||||
|
||||
createTripParkElement(park, index) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'park-card draggable-item';
|
||||
div.innerHTML = `
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
|
||||
${index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
${park.name}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
${park.formatted_location || 'Location not specified'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
updateTripMarkers() {
|
||||
// Clear existing trip markers
|
||||
Object.values(this.parkMarkers).forEach(marker => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
this.parkMarkers = {};
|
||||
|
||||
// Add markers for trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const marker = this.createTripMarker(park, index);
|
||||
this.parkMarkers[park.id] = marker;
|
||||
marker.addTo(this.map);
|
||||
});
|
||||
|
||||
// Fit map to show all trip parks
|
||||
if (this.tripParks.length > 0) {
|
||||
this.fitRoute();
|
||||
}
|
||||
}
|
||||
|
||||
createTripMarker(park, index) {
|
||||
let markerClass = 'waypoint-stop';
|
||||
if (index === 0) markerClass = 'waypoint-start';
|
||||
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: `waypoint-marker ${markerClass}`,
|
||||
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
|
||||
const marker = L.marker([park.latitude, park.longitude], { icon });
|
||||
|
||||
const popupContent = `
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
|
||||
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
|
||||
Remove from Trip
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
return marker;
|
||||
}
|
||||
|
||||
reorderTripParks(oldIndex, newIndex) {
|
||||
const park = this.tripParks.splice(oldIndex, 1)[0];
|
||||
this.tripParks.splice(newIndex, 0, park);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
|
||||
// Clear route to force recalculation
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
async optimizeRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
try {
|
||||
const parkIds = this.tripParks.map(p => p.id);
|
||||
const response = await fetch('{% url "parks:htmx_optimize_route" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({ park_ids: parkIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.optimized_order) {
|
||||
// Reorder parks based on optimization
|
||||
const optimizedParks = data.optimized_order.map(id =>
|
||||
this.tripParks.find(p => p.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
this.tripParks = optimizedParks;
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Route optimization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
// Remove existing route
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
}
|
||||
|
||||
const waypoints = this.tripParks.map(park =>
|
||||
L.latLng(park.latitude, park.longitude)
|
||||
);
|
||||
|
||||
this.routeControl = L.Routing.control({
|
||||
waypoints: waypoints,
|
||||
routeWhileDragging: false,
|
||||
addWaypoints: false,
|
||||
createMarker: () => null, // Don't create default markers
|
||||
lineOptions: {
|
||||
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
|
||||
}
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeControl.on('routesfound', (e) => {
|
||||
const route = e.routes[0];
|
||||
this.updateTripSummary(route);
|
||||
});
|
||||
}
|
||||
|
||||
updateTripSummary(route) {
|
||||
if (!route) return;
|
||||
|
||||
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
|
||||
const totalTime = this.formatDuration(route.summary.totalTime);
|
||||
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
||||
|
||||
document.getElementById('total-distance').textContent = totalDistance;
|
||||
document.getElementById('total-time').textContent = totalTime;
|
||||
document.getElementById('total-parks').textContent = this.tripParks.length;
|
||||
document.getElementById('total-rides').textContent = totalRides;
|
||||
|
||||
document.getElementById('trip-summary').classList.remove('hidden');
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
fitRoute() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const group = new L.featureGroup(Object.values(this.parkMarkers));
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
toggleAllParks() {
|
||||
// Implementation for showing/hiding all parks on the map
|
||||
const button = document.getElementById('toggle-parks');
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (this.showingAllParks) {
|
||||
// Hide all parks
|
||||
this.showingAllParks = false;
|
||||
icon.className = 'mr-1 fas fa-eye';
|
||||
button.innerHTML = icon.outerHTML + 'Show All Parks';
|
||||
} else {
|
||||
// Show all parks
|
||||
this.showingAllParks = true;
|
||||
icon.className = 'mr-1 fas fa-eye-slash';
|
||||
button.innerHTML = icon.outerHTML + 'Hide All Parks';
|
||||
this.displayAllParks();
|
||||
}
|
||||
}
|
||||
|
||||
displayAllParks() {
|
||||
// Add markers for all parks (implementation depends on requirements)
|
||||
this.allParks.forEach(park => {
|
||||
if (!this.parkMarkers[park.id]) {
|
||||
const marker = L.marker([park.latitude, park.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'location-marker location-marker-park',
|
||||
html: '<div class="location-marker-inner">🎢</div>',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '"')})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
Add to Trip
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.parkMarkers[`all_${park.id}`] = marker;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
const calculateBtn = document.getElementById('calculate-route');
|
||||
|
||||
const hasEnoughParks = this.tripParks.length >= 2;
|
||||
|
||||
optimizeBtn.disabled = !hasEnoughParks;
|
||||
calculateBtn.disabled = !hasEnoughParks;
|
||||
}
|
||||
|
||||
clearTrip() {
|
||||
this.tripParks = [];
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
|
||||
document.getElementById('trip-summary').classList.add('hidden');
|
||||
}
|
||||
|
||||
async saveTrip() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const tripName = prompt('Enter a name for this trip:');
|
||||
if (!tripName) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('{% url "parks:htmx_save_trip" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: tripName,
|
||||
park_ids: this.tripParks.map(p => p.id)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert('Trip saved successfully!');
|
||||
// Refresh saved trips
|
||||
htmx.trigger('#saved-trips', 'refresh');
|
||||
} else {
|
||||
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save trip failed:', error);
|
||||
alert('Failed to save trip');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function for adding parks from search results
|
||||
window.addParkToTrip = function(parkData) {
|
||||
window.tripPlanner.addParkToTrip(parkData);
|
||||
};
|
||||
|
||||
// Initialize trip planner when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.tripPlanner = new TripPlanner();
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
if (globalThis.RoadtripMap) {
|
||||
globalThis.RoadtripMap.init('map-container');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -5,8 +5,8 @@ This script demonstrates real-world scenarios for using the OSM Road Trip Servic
|
||||
in the ThrillWiki application.
|
||||
"""
|
||||
|
||||
from parks.models import Park
|
||||
from parks.services import RoadTripService
|
||||
from apps.parks.models import Park
|
||||
from apps.parks.services import RoadTripService
|
||||
import os
|
||||
import django
|
||||
|
||||
@@ -14,350 +14,378 @@ import django
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings")
|
||||
django.setup()
|
||||
|
||||
# New small helpers and constant to simplify functions and avoid repeated literals
|
||||
MAGIC_KINGDOM = "Magic Kingdom"
|
||||
|
||||
|
||||
def _format_coords(coords):
|
||||
"""
|
||||
Return (lat, lon) tuple or None for a coords object/sequence.
|
||||
Accepts objects with .latitude/.longitude or indexable (lat, lon).
|
||||
"""
|
||||
if not coords:
|
||||
return None
|
||||
lat = getattr(coords, "latitude", None)
|
||||
lon = getattr(coords, "longitude", None)
|
||||
if lat is not None and lon is not None:
|
||||
return (lat, lon)
|
||||
# Fallback to indexable
|
||||
try:
|
||||
return (coords[0], coords[1])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _print_route_summary(route, indent=" "):
|
||||
"""Safely print route summary fields if route is present."""
|
||||
if not route:
|
||||
return
|
||||
# Use attributes with fallback to dict keys if needed
|
||||
formatted_distance = getattr(route, "formatted_distance", None) or route.get(
|
||||
"formatted_distance", "N/A"
|
||||
) if isinstance(route, dict) else getattr(route, "formatted_distance", "N/A")
|
||||
formatted_duration = getattr(route, "formatted_duration", None) or route.get(
|
||||
"formatted_duration", "N/A"
|
||||
) if isinstance(route, dict) else getattr(route, "formatted_duration", "N/A")
|
||||
print(f"{indent}{formatted_distance}, {formatted_duration}")
|
||||
|
||||
|
||||
def demo_florida_theme_park_trip():
|
||||
"""
|
||||
Demonstrate planning a Florida theme park road trip.
|
||||
"""
|
||||
print("🏖️ Florida Theme Park Road Trip Planner")
|
||||
print("=" * 50)
|
||||
"""
|
||||
Demonstrate planning a Florida theme park road trip.
|
||||
"""
|
||||
print("🏖️ Florida Theme Park Road Trip Planner")
|
||||
print("=" * 50)
|
||||
|
||||
service = RoadTripService()
|
||||
service = RoadTripService()
|
||||
|
||||
# Define Florida theme parks with addresses
|
||||
florida_parks = [
|
||||
("Magic Kingdom", "Magic Kingdom Dr, Orlando, FL 32830"),
|
||||
(
|
||||
"Universal Studios Florida",
|
||||
"6000 Universal Blvd, Orlando, FL 32819",
|
||||
),
|
||||
("SeaWorld Orlando", "7007 Sea World Dr, Orlando, FL 32821"),
|
||||
("Busch Gardens Tampa", "10165 McKinley Dr, Tampa, FL 33612"),
|
||||
]
|
||||
# Define Florida theme parks with addresses
|
||||
florida_parks = [
|
||||
(MAGIC_KINGDOM, "Magic Kingdom Dr, Orlando, FL 32830"),
|
||||
("Universal Studios Florida", "6000 Universal Blvd, Orlando, FL 32819"),
|
||||
("SeaWorld Orlando", "7007 Sea World Dr, Orlando, FL 32821"),
|
||||
("Busch Gardens Tampa", "10165 McKinley Dr, Tampa, FL 33612"),
|
||||
]
|
||||
|
||||
print("Planning trip for these Florida parks:")
|
||||
park_coords = {}
|
||||
print("Planning trip for these Florida parks:")
|
||||
park_coords = {}
|
||||
|
||||
for name, address in florida_parks:
|
||||
print(f"\n📍 Geocoding {name}...")
|
||||
coords = service.geocode_address(address)
|
||||
if coords:
|
||||
park_coords[name] = coords
|
||||
print(
|
||||
f" ✅ Located at {
|
||||
coords.latitude:.4f}, {
|
||||
coords.longitude:.4f}"
|
||||
)
|
||||
else:
|
||||
print(f" ❌ Could not geocode {address}")
|
||||
# small helper to geocode and store
|
||||
def _geocode_and_store(name, address):
|
||||
print(f"\n📍 Geocoding {name}...")
|
||||
coords = service.geocode_address(address)
|
||||
if coords:
|
||||
latlon = _format_coords(coords)
|
||||
if latlon:
|
||||
park_coords[name] = coords
|
||||
print(f" ✅ Located at {latlon[0]:.4f}, {latlon[1]:.4f}")
|
||||
return True
|
||||
print(f" ❌ Could not geocode {address}")
|
||||
return False
|
||||
|
||||
if len(park_coords) < 2:
|
||||
print("❌ Need at least 2 parks to plan a trip")
|
||||
return
|
||||
for name, address in florida_parks:
|
||||
_geocode_and_store(name, address)
|
||||
|
||||
# Calculate distances between all parks
|
||||
print("\n🗺️ Distance Matrix:")
|
||||
park_names = list(park_coords.keys())
|
||||
if len(park_coords) < 2:
|
||||
print("❌ Need at least 2 parks to plan a trip")
|
||||
return
|
||||
|
||||
for i, park1 in enumerate(park_names):
|
||||
for j, park2 in enumerate(park_names):
|
||||
if i < j: # Only calculate each pair once
|
||||
route = service.calculate_route(park_coords[park1], park_coords[park2])
|
||||
if route:
|
||||
print(f" {park1} ↔ {park2}")
|
||||
print(
|
||||
f" {
|
||||
route.formatted_distance}, {
|
||||
route.formatted_duration}"
|
||||
)
|
||||
# Calculate distances between all parks
|
||||
print("\n🗺️ Distance Matrix:")
|
||||
park_names = list(park_coords.keys())
|
||||
|
||||
# Find central park for radiating searches
|
||||
print("\n🎢 Parks within 100km of Magic Kingdom:")
|
||||
magic_kingdom_coords = park_coords.get("Magic Kingdom")
|
||||
if magic_kingdom_coords:
|
||||
for name, coords in park_coords.items():
|
||||
if name != "Magic Kingdom":
|
||||
route = service.calculate_route(magic_kingdom_coords, coords)
|
||||
if route:
|
||||
print(
|
||||
f" {name}: {
|
||||
route.formatted_distance} ({
|
||||
route.formatted_duration})"
|
||||
)
|
||||
for i, park1 in enumerate(park_names):
|
||||
for j, park2 in enumerate(park_names):
|
||||
if i < j: # Only calculate each pair once
|
||||
route = service.calculate_route(park_coords[park1], park_coords[park2])
|
||||
if route:
|
||||
print(f" {park1} ↔ {park2}")
|
||||
_print_route_summary(route, indent=" ")
|
||||
|
||||
# Find central park for radiating searches
|
||||
print(f"\n🎢 Parks within 100km of {MAGIC_KINGDOM}:")
|
||||
magic_kingdom_coords = park_coords.get(MAGIC_KINGDOM)
|
||||
if magic_kingdom_coords:
|
||||
for name, coords in park_coords.items():
|
||||
if name != MAGIC_KINGDOM:
|
||||
route = service.calculate_route(magic_kingdom_coords, coords)
|
||||
if route:
|
||||
_print_route_summary(route, indent=f" {name}: ")
|
||||
|
||||
|
||||
def demo_cross_country_road_trip():
|
||||
"""
|
||||
Demonstrate planning a cross-country theme park road trip.
|
||||
"""
|
||||
print("\n\n🇺🇸 Cross-Country Theme Park Road Trip")
|
||||
print("=" * 50)
|
||||
"""
|
||||
Demonstrate planning a cross-country theme park road trip.
|
||||
"""
|
||||
print("\n\n🇺🇸 Cross-Country Theme Park Road Trip")
|
||||
print("=" * 50)
|
||||
|
||||
service = RoadTripService()
|
||||
service = RoadTripService()
|
||||
|
||||
# Major theme parks across the US
|
||||
major_parks = [
|
||||
("Disneyland", "1313 Disneyland Dr, Anaheim, CA 92802"),
|
||||
("Cedar Point", "1 Cedar Point Dr, Sandusky, OH 44870"),
|
||||
(
|
||||
"Six Flags Magic Mountain",
|
||||
"26101 Magic Mountain Pkwy, Valencia, CA 91355",
|
||||
),
|
||||
("Walt Disney World", "Walt Disney World Resort, Orlando, FL 32830"),
|
||||
]
|
||||
# Major theme parks across the US
|
||||
major_parks = [
|
||||
("Disneyland", "1313 Disneyland Dr, Anaheim, CA 92802"),
|
||||
("Cedar Point", "1 Cedar Point Dr, Sandusky, OH 44870"),
|
||||
("Six Flags Magic Mountain", "26101 Magic Mountain Pkwy, Valencia, CA 91355"),
|
||||
("Walt Disney World", "Walt Disney World Resort, Orlando, FL 32830"),
|
||||
]
|
||||
|
||||
print("Geocoding major US theme parks:")
|
||||
park_coords = {}
|
||||
print("Geocoding major US theme parks:")
|
||||
park_coords = {}
|
||||
|
||||
for name, address in major_parks:
|
||||
print(f"\n📍 {name}...")
|
||||
coords = service.geocode_address(address)
|
||||
if coords:
|
||||
park_coords[name] = coords
|
||||
print(f" ✅ {coords.latitude:.4f}, {coords.longitude:.4f}")
|
||||
for name, address in major_parks:
|
||||
print(f"\n📍 {name}...")
|
||||
coords = service.geocode_address(address)
|
||||
if coords:
|
||||
park_coords[name] = coords
|
||||
latlon = _format_coords(coords)
|
||||
if latlon:
|
||||
print(f" ✅ {latlon[0]:.4f}, {latlon[1]:.4f}")
|
||||
|
||||
if len(park_coords) >= 3:
|
||||
# Calculate an optimized route if we have DB parks
|
||||
print("\n🛣️ Optimized Route Planning:")
|
||||
print("Note: This would work with actual Park objects from the database")
|
||||
if len(park_coords) >= 3:
|
||||
# Calculate an optimized route if we have DB parks
|
||||
print("\n🛣️ Optimized Route Planning:")
|
||||
print("Note: This would work with actual Park objects from the database")
|
||||
|
||||
# Show distances for a potential route
|
||||
route_order = [
|
||||
"Disneyland",
|
||||
"Six Flags Magic Mountain",
|
||||
"Cedar Point",
|
||||
"Walt Disney World",
|
||||
]
|
||||
total_distance = 0
|
||||
total_time = 0
|
||||
# Show distances for a potential route
|
||||
route_order = [
|
||||
"Disneyland",
|
||||
"Six Flags Magic Mountain",
|
||||
"Cedar Point",
|
||||
"Walt Disney World",
|
||||
]
|
||||
total_distance = 0
|
||||
total_time = 0
|
||||
|
||||
for i in range(len(route_order) - 1):
|
||||
from_park = route_order[i]
|
||||
to_park = route_order[i + 1]
|
||||
for i in range(len(route_order) - 1):
|
||||
from_park = route_order[i]
|
||||
to_park = route_order[i + 1]
|
||||
|
||||
if from_park in park_coords and to_park in park_coords:
|
||||
route = service.calculate_route(
|
||||
park_coords[from_park], park_coords[to_park]
|
||||
)
|
||||
if route:
|
||||
total_distance += route.distance_km
|
||||
total_time += route.duration_minutes
|
||||
print(f" {i + 1}. {from_park} → {to_park}")
|
||||
print(
|
||||
f" {
|
||||
route.formatted_distance}, {
|
||||
route.formatted_duration}"
|
||||
)
|
||||
if from_park in park_coords and to_park in park_coords:
|
||||
route = service.calculate_route(park_coords[from_park], park_coords[to_park])
|
||||
if route:
|
||||
total_distance += getattr(route, "distance_km", 0) or route.get("distance_km", 0) if isinstance(route, dict) else getattr(route, "distance_km", 0)
|
||||
total_time += getattr(route, "duration_minutes", 0) or route.get("duration_minutes", 0) if isinstance(route, dict) else getattr(route, "duration_minutes", 0)
|
||||
print(f" {i + 1}. {from_park} → {to_park}")
|
||||
_print_route_summary(route, indent=" ")
|
||||
|
||||
print("\n📊 Trip Summary:")
|
||||
print(f" Total Distance: {total_distance:.1f}km")
|
||||
print(
|
||||
f" Total Driving Time: {
|
||||
total_time //
|
||||
60}h {
|
||||
total_time %
|
||||
60}min"
|
||||
)
|
||||
print(f" Average Distance per Leg: {total_distance / 3:.1f}km")
|
||||
print("\n📊 Trip Summary:")
|
||||
print(f" Total Distance: {total_distance:.1f}km")
|
||||
hours = total_time // 60
|
||||
mins = total_time % 60
|
||||
print(f" Total Driving Time: {hours}h {mins}min")
|
||||
# avoid division by zero
|
||||
legs = max(1, len(route_order) - 1)
|
||||
print(f" Average Distance per Leg: {total_distance / legs:.1f}km")
|
||||
|
||||
|
||||
def demo_database_integration():
|
||||
"""
|
||||
Demonstrate working with actual parks from the database.
|
||||
"""
|
||||
print("\n\n🗄️ Database Integration Demo")
|
||||
print("=" * 50)
|
||||
"""
|
||||
Demonstrate working with actual parks from the database.
|
||||
"""
|
||||
print("\n\n🗄️ Database Integration Demo")
|
||||
print("=" * 50)
|
||||
|
||||
service = RoadTripService()
|
||||
service = RoadTripService()
|
||||
|
||||
# Get parks that have location data
|
||||
parks_with_location = Park.objects.filter(
|
||||
location__point__isnull=False
|
||||
).select_related("location")[:5]
|
||||
# Get parks that have location data
|
||||
parks_with_location = Park.objects.filter(location__point__isnull=False).select_related("location")[:5]
|
||||
|
||||
if not parks_with_location:
|
||||
print("❌ No parks with location data found in database")
|
||||
return
|
||||
if not parks_with_location:
|
||||
print("❌ No parks with location data found in database")
|
||||
return
|
||||
|
||||
print(f"Found {len(parks_with_location)} parks with location data:")
|
||||
print(f"Found {len(parks_with_location)} parks with location data:")
|
||||
|
||||
for park in parks_with_location:
|
||||
coords = park.coordinates
|
||||
if coords:
|
||||
print(f" 🎢 {park.name}: {coords[0]:.4f}, {coords[1]:.4f}")
|
||||
for park in parks_with_location:
|
||||
coords = getattr(park, "coordinates", None)
|
||||
latlon = _format_coords(coords)
|
||||
if latlon:
|
||||
print(f" 🎢 {park.name}: {latlon[0]:.4f}, {latlon[1]:.4f}")
|
||||
|
||||
# Demonstrate nearby park search
|
||||
if len(parks_with_location) >= 1:
|
||||
center_park = parks_with_location[0]
|
||||
print(f"\n🔍 Finding parks within 500km of {center_park.name}:")
|
||||
# Demonstrate nearby park search
|
||||
if len(parks_with_location) >= 1:
|
||||
center_park = parks_with_location[0]
|
||||
print(f"\n🔍 Finding parks within 500km of {center_park.name}:")
|
||||
|
||||
nearby_parks = service.get_park_distances(center_park, radius_km=500)
|
||||
nearby_parks = service.get_park_distances(center_park, radius_km=500)
|
||||
|
||||
if nearby_parks:
|
||||
print(f" Found {len(nearby_parks)} nearby parks:")
|
||||
for result in nearby_parks[:3]: # Show top 3
|
||||
park = result["park"]
|
||||
print(
|
||||
f" 📍 {
|
||||
park.name}: {
|
||||
result['formatted_distance']} ({
|
||||
result['formatted_duration']})"
|
||||
)
|
||||
else:
|
||||
print(" No nearby parks found (may need larger radius)")
|
||||
if nearby_parks:
|
||||
print(f" Found {len(nearby_parks)} nearby parks:")
|
||||
for result in nearby_parks[:3]: # Show top 3
|
||||
park = result.get("park") if isinstance(result, dict) else getattr(result, "park", None)
|
||||
# use safe formatted strings
|
||||
formatted_distance = result.get("formatted_distance", "N/A") if isinstance(result, dict) else getattr(result, "formatted_distance", "N/A")
|
||||
formatted_duration = result.get("formatted_duration", "N/A") if isinstance(result, dict) else getattr(result, "formatted_duration", "N/A")
|
||||
if park:
|
||||
print(f" 📍 {park.name}: {formatted_distance} ({formatted_duration})")
|
||||
else:
|
||||
print(" No nearby parks found (may need larger radius)")
|
||||
|
||||
# Demonstrate multi-park trip planning
|
||||
if len(parks_with_location) >= 3:
|
||||
selected_parks = list(parks_with_location)[:3]
|
||||
print("\n🗺️ Planning optimized trip for 3 parks:")
|
||||
# Demonstrate multi-park trip planning
|
||||
if len(parks_with_location) >= 3:
|
||||
selected_parks = list(parks_with_location)[:3]
|
||||
print("\n🗺️ Planning optimized trip for 3 parks:")
|
||||
|
||||
for park in selected_parks:
|
||||
print(f" - {park.name}")
|
||||
for park in selected_parks:
|
||||
print(f" - {park.name}")
|
||||
|
||||
trip = service.create_multi_park_trip(selected_parks)
|
||||
trip = service.create_multi_park_trip(selected_parks)
|
||||
|
||||
if trip:
|
||||
print("\n✅ Optimized Route:")
|
||||
print(f" Total Distance: {trip.formatted_total_distance}")
|
||||
print(f" Total Duration: {trip.formatted_total_duration}")
|
||||
print(" Route:")
|
||||
if trip:
|
||||
print("\n✅ Optimized Route:")
|
||||
print(f" Total Distance: {getattr(trip, 'formatted_total_distance', 'N/A')}")
|
||||
print(f" Total Duration: {getattr(trip, 'formatted_total_duration', 'N/A')}")
|
||||
print(" Route:")
|
||||
|
||||
for i, leg in enumerate(trip.legs, 1):
|
||||
print(f" {i}. {leg.from_park.name} → {leg.to_park.name}")
|
||||
print(
|
||||
f" {
|
||||
leg.route.formatted_distance}, {
|
||||
leg.route.formatted_duration}"
|
||||
)
|
||||
else:
|
||||
print(" ❌ Could not optimize trip route")
|
||||
for i, leg in enumerate(getattr(trip, "legs", []) or [], 1):
|
||||
from_park = getattr(leg, "from_park", None)
|
||||
to_park = getattr(leg, "to_park", None)
|
||||
route = getattr(leg, "route", None)
|
||||
if from_park and to_park:
|
||||
print(f" {i}. {from_park.name} → {to_park.name}")
|
||||
_print_route_summary(route, indent=" ")
|
||||
else:
|
||||
print(" ❌ Could not optimize trip route")
|
||||
|
||||
|
||||
def demo_geocoding_fallback():
|
||||
"""
|
||||
Demonstrate geocoding parks that don't have coordinates.
|
||||
"""
|
||||
print("\n\n🌍 Geocoding Demo")
|
||||
print("=" * 50)
|
||||
"""
|
||||
Demonstrate geocoding parks that don't have coordinates.
|
||||
"""
|
||||
print("\n\n🌍 Geocoding Demo")
|
||||
print("=" * 50)
|
||||
|
||||
service = RoadTripService()
|
||||
service = RoadTripService()
|
||||
|
||||
# Get parks without location data
|
||||
parks_without_coords = Park.objects.filter(
|
||||
location__point__isnull=True
|
||||
).select_related("location")[:3]
|
||||
# Get parks without location data
|
||||
parks_without_coords = Park.objects.filter(location__point__isnull=True).select_related("location")[:3]
|
||||
|
||||
if not parks_without_coords:
|
||||
print("✅ All parks already have coordinates")
|
||||
return
|
||||
if not parks_without_coords:
|
||||
print("✅ All parks already have coordinates")
|
||||
return
|
||||
|
||||
print(f"Found {len(parks_without_coords)} parks without coordinates:")
|
||||
print(f"Found {len(parks_without_coords)} parks without coordinates:")
|
||||
|
||||
for park in parks_without_coords:
|
||||
print(f"\n🎢 {park.name}")
|
||||
for park in parks_without_coords:
|
||||
print(f"\n🎢 {park.name}")
|
||||
|
||||
if hasattr(park, "location") and park.location:
|
||||
location = park.location
|
||||
address_parts = [
|
||||
park.name,
|
||||
location.street_address,
|
||||
location.city,
|
||||
location.state,
|
||||
location.country,
|
||||
]
|
||||
address = ", ".join(part for part in address_parts if part)
|
||||
print(f" Address: {address}")
|
||||
location = getattr(park, "location", None)
|
||||
if location:
|
||||
# use getattr to avoid attribute errors
|
||||
address_parts = [
|
||||
getattr(park, "name", None),
|
||||
getattr(location, "street_address", None),
|
||||
getattr(location, "city", None),
|
||||
getattr(location, "state", None),
|
||||
getattr(location, "country", None),
|
||||
]
|
||||
address = ", ".join(part for part in address_parts if part)
|
||||
print(f" Address: {address}")
|
||||
|
||||
# Try to geocode
|
||||
success = service.geocode_park_if_needed(park)
|
||||
if success:
|
||||
coords = park.coordinates
|
||||
print(f" ✅ Geocoded to: {coords[0]:.4f}, {coords[1]:.4f}")
|
||||
else:
|
||||
print(" ❌ Geocoding failed")
|
||||
else:
|
||||
print(" ❌ No location data available")
|
||||
# Try to geocode
|
||||
success = service.geocode_park_if_needed(park)
|
||||
if success:
|
||||
coords = getattr(park, "coordinates", None)
|
||||
latlon = _format_coords(coords)
|
||||
if latlon:
|
||||
print(f" ✅ Geocoded to: {latlon[0]:.4f}, {latlon[1]:.4f}")
|
||||
else:
|
||||
print(" ✅ Geocoded but coordinates unavailable")
|
||||
else:
|
||||
print(" ❌ Geocoding failed")
|
||||
else:
|
||||
print(" ❌ No location data available")
|
||||
|
||||
|
||||
def demo_cache_performance():
|
||||
"""
|
||||
Demonstrate caching performance benefits.
|
||||
"""
|
||||
print("\n\n⚡ Cache Performance Demo")
|
||||
print("=" * 50)
|
||||
"""
|
||||
Demonstrate caching performance benefits.
|
||||
"""
|
||||
print("\n\n⚡ Cache Performance Demo")
|
||||
print("=" * 50)
|
||||
|
||||
service = RoadTripService()
|
||||
service = RoadTripService()
|
||||
|
||||
import time
|
||||
import time
|
||||
|
||||
# Test address for geocoding
|
||||
test_address = "Disneyland, Anaheim, CA"
|
||||
# Test address for geocoding
|
||||
test_address = "Disneyland, Anaheim, CA"
|
||||
|
||||
print(f"Testing cache performance with: {test_address}")
|
||||
print(f"Testing cache performance with: {test_address}")
|
||||
|
||||
# First request (cache miss)
|
||||
print("\n1️⃣ First request (cache miss):")
|
||||
start_time = time.time()
|
||||
coords1 = service.geocode_address(test_address)
|
||||
first_duration = time.time() - start_time
|
||||
# First request (cache miss)
|
||||
print("\n1️⃣ First request (cache miss):")
|
||||
start_time = time.time()
|
||||
coords1 = service.geocode_address(test_address)
|
||||
first_duration = time.time() - start_time
|
||||
|
||||
if coords1:
|
||||
print(f" ✅ Result: {coords1.latitude:.4f}, {coords1.longitude:.4f}")
|
||||
print(f" ⏱️ Duration: {first_duration:.2f} seconds")
|
||||
if coords1:
|
||||
latlon = _format_coords(coords1)
|
||||
if latlon:
|
||||
print(f" ✅ Result: {latlon[0]:.4f}, {latlon[1]:.4f}")
|
||||
else:
|
||||
print(" ✅ Result obtained")
|
||||
print(f" ⏱️ Duration: {first_duration:.2f} seconds")
|
||||
|
||||
# Second request (cache hit)
|
||||
print("\n2️⃣ Second request (cache hit):")
|
||||
start_time = time.time()
|
||||
coords2 = service.geocode_address(test_address)
|
||||
second_duration = time.time() - start_time
|
||||
# Second request (cache hit)
|
||||
print("\n2️⃣ Second request (cache hit):")
|
||||
start_time = time.time()
|
||||
coords2 = service.geocode_address(test_address)
|
||||
second_duration = time.time() - start_time
|
||||
|
||||
if coords2:
|
||||
print(f" ✅ Result: {coords2.latitude:.4f}, {coords2.longitude:.4f}")
|
||||
print(f" ⏱️ Duration: {second_duration:.2f} seconds")
|
||||
if coords2:
|
||||
latlon2 = _format_coords(coords2)
|
||||
if latlon2:
|
||||
print(f" ✅ Result: {latlon2[0]:.4f}, {latlon2[1]:.4f}")
|
||||
else:
|
||||
print(" ✅ Result obtained")
|
||||
print(f" ⏱️ Duration: {second_duration:.2f} seconds")
|
||||
|
||||
if first_duration > second_duration:
|
||||
speedup = first_duration / second_duration
|
||||
print(f" 🚀 Cache speedup: {speedup:.1f}x faster")
|
||||
if first_duration > second_duration and second_duration > 0:
|
||||
speedup = first_duration / second_duration
|
||||
print(f" 🚀 Cache speedup: {speedup:.1f}x faster")
|
||||
|
||||
if (
|
||||
coords1.latitude == coords2.latitude
|
||||
and coords1.longitude == coords2.longitude
|
||||
):
|
||||
print(" ✅ Results identical (cache working)")
|
||||
# Compare coordinates if both present
|
||||
if coords1 and coords2:
|
||||
latlon1 = _format_coords(coords1)
|
||||
latlon2 = _format_coords(coords2)
|
||||
if latlon1 and latlon2 and latlon1 == latlon2:
|
||||
print(" ✅ Results identical (cache working)")
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Run all demonstration scenarios.
|
||||
"""
|
||||
print("🎢 ThrillWiki Road Trip Service Demo")
|
||||
print("This demo shows practical usage scenarios for the OSM Road Trip Service")
|
||||
"""
|
||||
Run all demonstration scenarios.
|
||||
"""
|
||||
print("🎢 ThrillWiki Road Trip Service Demo")
|
||||
print("This demo shows practical usage scenarios for the OSM Road Trip Service")
|
||||
|
||||
try:
|
||||
demo_florida_theme_park_trip()
|
||||
demo_cross_country_road_trip()
|
||||
demo_database_integration()
|
||||
demo_geocoding_fallback()
|
||||
demo_cache_performance()
|
||||
try:
|
||||
demo_florida_theme_park_trip()
|
||||
demo_cross_country_road_trip()
|
||||
demo_database_integration()
|
||||
demo_geocoding_fallback()
|
||||
demo_cache_performance()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("🎉 Demo completed successfully!")
|
||||
print("\nThe Road Trip Service is ready for integration into ThrillWiki!")
|
||||
print("\nKey Features Demonstrated:")
|
||||
print("✅ Geocoding theme park addresses")
|
||||
print("✅ Route calculation with distance/time")
|
||||
print("✅ Multi-park trip optimization")
|
||||
print("✅ Database integration with Park models")
|
||||
print("✅ Caching for performance")
|
||||
print("✅ Rate limiting for OSM compliance")
|
||||
print("✅ Error handling and fallbacks")
|
||||
print("\n" + "=" * 50)
|
||||
print("🎉 Demo completed successfully!")
|
||||
print("\nThe Road Trip Service is ready for integration into ThrillWiki!")
|
||||
print("\nKey Features Demonstrated:")
|
||||
print("✅ Geocoding theme park addresses")
|
||||
print("✅ Route calculation with distance/time")
|
||||
print("✅ Multi-park trip optimization")
|
||||
print("✅ Database integration with Park models")
|
||||
print("✅ Caching for performance")
|
||||
print("✅ Rate limiting for OSM compliance")
|
||||
print("✅ Error handling and fallbacks")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Demo failed with error: {e}")
|
||||
import traceback
|
||||
except Exception as e:
|
||||
print(f"\n❌ Demo failed with error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
258
docs/FRONTEND_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Frontend Migration Plan: React/Next.js to HTMX + Alpine.js
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Based on my analysis, this project already has a **fully functional HTMX + Alpine.js Django backend** with comprehensive templates. The task is to migrate the separate Next.js React frontend (`frontend/` directory) to integrate seamlessly with the existing Django HTMX + Alpine.js architecture.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### ✅ Django Backend (Already Complete)
|
||||
- **HTMX Integration**: Already implemented with proper headers and partial templates
|
||||
- **Alpine.js Components**: Extensive use of Alpine.js for interactivity
|
||||
- **Template Structure**: Comprehensive template hierarchy with partials
|
||||
- **Authentication**: Complete auth system with modals and forms
|
||||
- **Styling**: Tailwind CSS with dark mode support
|
||||
- **Components**: Reusable components for cards, pagination, forms, etc.
|
||||
|
||||
### 🔄 React Frontend (To Be Migrated)
|
||||
- **Next.js App Router**: Modern React application structure
|
||||
- **Component Library**: Extensive UI components using shadcn/ui
|
||||
- **Authentication**: React-based auth hooks and providers
|
||||
- **Theme Management**: React theme provider system
|
||||
- **API Integration**: TypeScript API clients for Django backend
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Template Enhancement (Extend Django Templates)
|
||||
|
||||
Instead of replacing the existing Django templates, we'll enhance them to match the React frontend's design and functionality.
|
||||
|
||||
#### 1.1 Header Component Migration
|
||||
**Current Django**: Basic header with navigation
|
||||
**React Frontend**: Advanced header with browse menu, search, theme toggle, user dropdown
|
||||
|
||||
**Action**: Enhance `backend/templates/base/base.html` header section
|
||||
|
||||
#### 1.2 Component Library Integration
|
||||
**Current Django**: Basic components
|
||||
**React Frontend**: Rich component library (buttons, cards, modals, etc.)
|
||||
|
||||
**Action**: Create Django template components matching shadcn/ui design system
|
||||
|
||||
#### 1.3 Advanced Interactivity
|
||||
**Current Django**: Basic Alpine.js usage
|
||||
**React Frontend**: Complex state management and interactions
|
||||
|
||||
**Action**: Enhance Alpine.js components with advanced patterns
|
||||
|
||||
### Phase 2: Django View Enhancements
|
||||
|
||||
#### 2.1 API Response Optimization
|
||||
- Enhance existing Django views to support both full page and HTMX partial responses
|
||||
- Implement proper JSON responses for Alpine.js components
|
||||
- Add advanced filtering and search capabilities
|
||||
|
||||
#### 2.2 Authentication Flow
|
||||
- Enhance existing Django auth to match React frontend UX
|
||||
- Implement modal-based login/signup (already partially done)
|
||||
- Add proper error handling and validation
|
||||
|
||||
### Phase 3: Frontend Asset Migration
|
||||
|
||||
#### 3.1 Static Assets
|
||||
- Migrate React component styles to Django static files
|
||||
- Enhance Tailwind configuration
|
||||
- Add missing JavaScript utilities
|
||||
|
||||
#### 3.2 Alpine.js Store Management
|
||||
- Implement global state management using Alpine.store()
|
||||
- Create reusable Alpine.js components using Alpine.data()
|
||||
- Add proper event handling and communication
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Analyze Component Gaps
|
||||
Compare React components with Django templates to identify missing functionality:
|
||||
|
||||
1. **Browse Menu**: React has sophisticated browse dropdown
|
||||
2. **Search Functionality**: React has advanced search with autocomplete
|
||||
3. **Theme Toggle**: React has system/light/dark theme support
|
||||
4. **User Management**: React has comprehensive user profile management
|
||||
5. **Modal System**: React has advanced modal components
|
||||
6. **Form Handling**: React has sophisticated form validation
|
||||
|
||||
### Step 2: Enhance Django Templates
|
||||
|
||||
#### Base Template Enhancements
|
||||
```html
|
||||
<!-- Enhanced header with browse menu -->
|
||||
<div class="browse-menu" x-data="browseMenu()">
|
||||
<!-- Implement React-style browse menu -->
|
||||
</div>
|
||||
|
||||
<!-- Enhanced search with autocomplete -->
|
||||
<div class="search-container" x-data="searchComponent()">
|
||||
<!-- Implement React-style search -->
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Alpine.js Component Library
|
||||
```javascript
|
||||
// Global Alpine.js components
|
||||
Alpine.data('browseMenu', () => ({
|
||||
open: false,
|
||||
toggle() { this.open = !this.open }
|
||||
}))
|
||||
|
||||
Alpine.data('searchComponent', () => ({
|
||||
query: '',
|
||||
results: [],
|
||||
async search() {
|
||||
// Implement search logic
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
### Step 3: Django View Enhancements
|
||||
|
||||
#### Enhanced Views for HTMX
|
||||
```python
|
||||
def enhanced_park_list(request):
|
||||
if request.headers.get('HX-Request'):
|
||||
# Return partial template for HTMX
|
||||
return render(request, 'parks/partials/park_list.html', context)
|
||||
# Return full page
|
||||
return render(request, 'parks/park_list.html', context)
|
||||
```
|
||||
|
||||
### Step 4: Component Migration Priority
|
||||
|
||||
1. **Header Component** (High Priority)
|
||||
- Browse menu with categories
|
||||
- Advanced search with autocomplete
|
||||
- User dropdown with profile management
|
||||
- Theme toggle with system preference
|
||||
|
||||
2. **Navigation Components** (High Priority)
|
||||
- Mobile menu with slide-out
|
||||
- Breadcrumb navigation
|
||||
- Tab navigation
|
||||
|
||||
3. **Form Components** (Medium Priority)
|
||||
- Advanced form validation
|
||||
- File upload components
|
||||
- Multi-step forms
|
||||
|
||||
4. **Data Display Components** (Medium Priority)
|
||||
- Advanced card layouts
|
||||
- Data tables with sorting/filtering
|
||||
- Pagination components
|
||||
|
||||
5. **Modal and Dialog Components** (Low Priority)
|
||||
- Confirmation dialogs
|
||||
- Image galleries
|
||||
- Settings panels
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### HTMX Patterns to Implement
|
||||
|
||||
1. **Lazy Loading**
|
||||
```html
|
||||
<div hx-get="/api/parks/" hx-trigger="intersect" hx-swap="innerHTML">
|
||||
Loading parks...
|
||||
</div>
|
||||
```
|
||||
|
||||
2. **Infinite Scroll**
|
||||
```html
|
||||
<div hx-get="/api/parks/?page=2" hx-trigger="revealed" hx-swap="beforeend">
|
||||
Load more...
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **Live Search**
|
||||
```html
|
||||
<input hx-get="/api/search/" hx-trigger="input changed delay:300ms"
|
||||
hx-target="#search-results">
|
||||
```
|
||||
|
||||
### Alpine.js Patterns to Implement
|
||||
|
||||
1. **Global State Management**
|
||||
```javascript
|
||||
Alpine.store('app', {
|
||||
user: null,
|
||||
theme: 'system',
|
||||
searchQuery: ''
|
||||
})
|
||||
```
|
||||
|
||||
2. **Reusable Components**
|
||||
```javascript
|
||||
Alpine.data('modal', () => ({
|
||||
open: false,
|
||||
show() { this.open = true },
|
||||
hide() { this.open = false }
|
||||
}))
|
||||
```
|
||||
|
||||
## File Structure After Migration
|
||||
|
||||
```
|
||||
backend/
|
||||
├── templates/
|
||||
│ ├── base/
|
||||
│ │ ├── base.html (enhanced)
|
||||
│ │ └── components/
|
||||
│ │ ├── header.html
|
||||
│ │ ├── footer.html
|
||||
│ │ ├── navigation.html
|
||||
│ │ └── search.html
|
||||
│ ├── components/
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── button.html
|
||||
│ │ │ ├── card.html
|
||||
│ │ │ ├── modal.html
|
||||
│ │ │ └── form.html
|
||||
│ │ └── layout/
|
||||
│ │ ├── browse_menu.html
|
||||
│ │ └── user_menu.html
|
||||
│ └── partials/
|
||||
│ ├── htmx/
|
||||
│ └── alpine/
|
||||
├── static/
|
||||
│ ├── js/
|
||||
│ │ ├── alpine-components.js
|
||||
│ │ ├── htmx-config.js
|
||||
│ │ └── app.js
|
||||
│ └── css/
|
||||
│ ├── components.css
|
||||
│ └── tailwind.css
|
||||
```
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Functionality Parity**: All React frontend features work in Django templates
|
||||
2. **Design Consistency**: Visual design matches React frontend exactly
|
||||
3. **Performance**: Page load times improved due to server-side rendering
|
||||
4. **User Experience**: Smooth interactions with HTMX and Alpine.js
|
||||
5. **Maintainability**: Clean, reusable template components
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- **Phase 1**: Template Enhancement (3-4 days)
|
||||
- **Phase 2**: Django View Enhancements (2-3 days)
|
||||
- **Phase 3**: Frontend Asset Migration (2-3 days)
|
||||
- **Testing & Refinement**: 2-3 days
|
||||
|
||||
**Total Estimated Time**: 9-13 days
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate**: Start with header component migration
|
||||
2. **Priority**: Focus on high-impact components first
|
||||
3. **Testing**: Implement comprehensive testing for each migrated component
|
||||
4. **Documentation**: Update all documentation to reflect new architecture
|
||||
|
||||
This migration will result in a unified, server-rendered application with the rich interactivity of the React frontend but the performance and simplicity of HTMX + Alpine.js.
|
||||
243
docs/fresh-project-status-2025-01-05.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Fresh Project Status - August 23, 2025
|
||||
|
||||
**Analysis Date:** August 23, 2025
|
||||
**Analysis Method:** Direct observation of current project state only
|
||||
**Analyst:** Claude (Fresh perspective, no prior documentation consulted)
|
||||
|
||||
## Project Overview
|
||||
|
||||
### Project Identity
|
||||
- **Name:** ThrillWiki Django (No React)
|
||||
- **Type:** Django web application for theme park and ride information
|
||||
- **Location:** `/Users/talor/thrillwiki_django_no_react`
|
||||
|
||||
### Current Running State
|
||||
- **Development Server:** Uses sophisticated startup script at `./scripts/dev_server.sh`
|
||||
- **Command Used:** `lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; ./scripts/dev_server.sh`
|
||||
- **Package Manager:** UV (Ultraviolet Python package manager) - pyproject.toml based
|
||||
- **CSS Framework:** Tailwind CSS with CLI integration
|
||||
- **Settings Module:** Auto-detecting with `config.django.local` for development
|
||||
|
||||
## Technical Stack Analysis
|
||||
|
||||
### Backend Framework
|
||||
- **Django:** 5.1.6 (Updated from 5.0)
|
||||
- **Database:** PostgreSQL with PostGIS (GeoDjango features)
|
||||
- **History Tracking:** django-pghistory 3.5.2 for comprehensive model change tracking
|
||||
- **Package Management:** UV with pyproject.toml (modern Python dependency management)
|
||||
- **Python Version:** Requires Python >=3.13
|
||||
|
||||
### Frontend Approach
|
||||
- **No React:** Project explicitly excludes React (per directory name)
|
||||
- **Tailwind CSS:** Version 4.0.1 with CLI integration
|
||||
- **HTMX:** Version 1.22.0 for dynamic interactions
|
||||
- **Autocomplete:** django-htmx-autocomplete for search functionality
|
||||
|
||||
### Key Libraries & Versions (Updated)
|
||||
- **django-pghistory:** 3.5.2 - PostgreSQL-based model history tracking
|
||||
- **djangorestframework:** 3.15.2 - API framework
|
||||
- **django-cors-headers:** 4.7.0 - CORS handling
|
||||
- **django-allauth:** 65.4.1 - Authentication system
|
||||
- **django-htmx:** 1.22.0 - HTMX integration
|
||||
- **drf-spectacular:** 0.27.0 - OpenAPI documentation
|
||||
- **django-silk:** 5.0.0 - Performance profiling
|
||||
- **django-debug-toolbar:** 4.0.0 - Development debugging
|
||||
|
||||
## Current Entity Architecture
|
||||
|
||||
### Core Business Entities
|
||||
|
||||
#### 1. Parks (`parks/` app)
|
||||
- **Purpose:** Theme parks and amusement venues
|
||||
- **Models:** Park, ParkArea, ParkLocation, ParkReview, Company (aliased as Operator), CompanyHeadquarters
|
||||
- **Key Features:**
|
||||
- Advanced location integration with GeoDjango
|
||||
- Comprehensive filtering and search
|
||||
- Road trip planning integration
|
||||
- Performance-optimized querysets
|
||||
- **Status:** Fully mature implementation with extensive views and API endpoints
|
||||
|
||||
#### 2. Rides (`rides/` app)
|
||||
- **Purpose:** Individual ride installations at parks
|
||||
- **Models:** Ride, RideModel, RollerCoasterStats, RideLocation, RideReview, Company (aliased as Manufacturer)
|
||||
- **Key Features:**
|
||||
- Detailed roller coaster statistics
|
||||
- Category-based organization
|
||||
- Location tracking
|
||||
- Review system integration
|
||||
- **Status:** Comprehensive implementation with specialized coaster data
|
||||
|
||||
#### 3. Company Entities (Within Apps)
|
||||
- **Parks Company:** Aliased as `Operator` for park operation companies
|
||||
- **Rides Company:** Aliased as `Manufacturer` for ride manufacturing companies
|
||||
- **Architecture:** Uses model aliases rather than separate apps for clarity
|
||||
- **Status:** Implemented within existing apps with clear semantic naming
|
||||
|
||||
### Supporting Systems
|
||||
|
||||
#### 4. Accounts (`accounts/` app)
|
||||
- **Purpose:** User management and authentication
|
||||
- **Features:** Custom user model, social authentication, profile management
|
||||
- **Status:** Complete with allauth integration
|
||||
|
||||
#### 5. Location (`location/` app)
|
||||
- **Purpose:** Geographic data and mapping services
|
||||
- **Features:** GeoDjango integration, geocoding, location search
|
||||
- **Status:** Integrated with parks and rides for location tracking
|
||||
|
||||
#### 6. Media (`media/` app)
|
||||
- **Purpose:** File and photo management
|
||||
- **Features:** Organized media storage, image handling with EXIF support
|
||||
- **Status:** Comprehensive media management system
|
||||
|
||||
#### 7. Core (`core/` app)
|
||||
- **Purpose:** Shared functionality, middleware, and utilities
|
||||
- **Features:** Custom middleware, health checks, performance monitoring
|
||||
- **Status:** Extensive core functionality with monitoring tools
|
||||
|
||||
#### 8. Moderation (`moderation/` app)
|
||||
- **Purpose:** Content moderation and administration
|
||||
- **Features:** Moderation workflows, admin tools
|
||||
- **Status:** Integrated moderation system
|
||||
|
||||
#### 9. Email Service (`email_service/` app)
|
||||
- **Purpose:** Email handling and notifications
|
||||
- **Features:** Custom email backends, notification system
|
||||
- **Status:** Complete email service implementation
|
||||
|
||||
## Current Configuration Architecture
|
||||
|
||||
### Settings Structure
|
||||
- **Base Settings:** `config/django/base.py` - comprehensive base configuration
|
||||
- **Local Settings:** `config/django/local.py` - development-optimized settings
|
||||
- **Production Settings:** `config/django/production.py` - production configuration
|
||||
- **Auto-Detection:** Smart environment detection in `manage.py`
|
||||
|
||||
### Development Tools Integration
|
||||
- **Silk Profiler:** Advanced performance profiling with SQL query analysis
|
||||
- **Debug Toolbar:** Comprehensive debugging information
|
||||
- **NPlusOne Detection:** Automatic N+1 query detection and warnings
|
||||
- **Performance Middleware:** Custom performance monitoring
|
||||
- **Health Checks:** Multi-layered health check system
|
||||
|
||||
### Database & Cache Configuration
|
||||
- **Database:** PostgreSQL with PostGIS for geographic features
|
||||
- **Cache:** Redis for production, locmem for development
|
||||
- **Session Storage:** Redis-backed sessions for performance
|
||||
- **Query Optimization:** Extensive use of select_related and prefetch_related
|
||||
|
||||
## Implementation Status Analysis
|
||||
|
||||
### Completed Features
|
||||
- **Models:** Fully implemented with history tracking for all core entities
|
||||
- **Admin Interface:** Comprehensive admin customization with geographic support
|
||||
- **API:** Complete REST API with OpenAPI documentation
|
||||
- **Templates:** Sophisticated template system with HTMX integration
|
||||
- **Search:** Advanced search with autocomplete and filtering
|
||||
- **Location Services:** Full GeoDjango integration with mapping
|
||||
- **Authentication:** Complete user management with social auth
|
||||
- **Performance:** Advanced monitoring and optimization tools
|
||||
|
||||
### Architecture Patterns
|
||||
- **Service Layer:** Comprehensive service classes for business logic
|
||||
- **Manager/QuerySet Pattern:** Optimized database queries with custom managers
|
||||
- **Selector Pattern:** Clean separation of data access logic
|
||||
- **History Tracking:** Automatic change auditing for all major entities
|
||||
- **Slug Management:** Intelligent URL-friendly identifiers with history
|
||||
|
||||
### Advanced Features
|
||||
- **Road Trip Planning:** Sophisticated route planning and optimization
|
||||
- **Performance Monitoring:** Real-time performance tracking and alerting
|
||||
- **Health Checks:** Multi-tier health monitoring system
|
||||
- **API Documentation:** Auto-generated OpenAPI 3.0 documentation
|
||||
- **Geographic Search:** Advanced location-based search and filtering
|
||||
|
||||
## Development Workflow & Tooling
|
||||
|
||||
### Modern Development Setup
|
||||
- **UV Package Management:** Fast, modern Python dependency management
|
||||
- **Auto-detecting Settings:** Intelligent environment detection
|
||||
- **Development Server Script:** Comprehensive startup automation with:
|
||||
- Port cleanup and cache clearing
|
||||
- Database migration checks
|
||||
- Static file collection
|
||||
- Tailwind CSS building
|
||||
- System health checks
|
||||
- Auto superuser creation
|
||||
|
||||
### Code Quality Tools
|
||||
- **Black:** Code formatting (version 25.1.0)
|
||||
- **Flake8:** Linting (version 7.1.1)
|
||||
- **Pytest:** Testing framework with Django integration
|
||||
- **Coverage:** Code coverage analysis
|
||||
- **Type Hints:** Enhanced type checking with stubs
|
||||
|
||||
### Performance & Monitoring
|
||||
- **Silk Integration:** SQL query profiling and performance analysis
|
||||
- **Debug Toolbar:** Development debugging with comprehensive panels
|
||||
- **Custom Middleware:** Performance tracking and query optimization
|
||||
- **Health Checks:** Database, cache, storage, and custom application checks
|
||||
|
||||
## Current Development State
|
||||
|
||||
### Project Maturity
|
||||
- **Architecture:** Highly sophisticated with clear separation of concerns
|
||||
- **Performance:** Production-ready with extensive optimization
|
||||
- **Testing:** Comprehensive test infrastructure
|
||||
- **Documentation:** Auto-generated API docs and extensive inline documentation
|
||||
- **Monitoring:** Enterprise-grade health and performance monitoring
|
||||
|
||||
### Technical Sophistication
|
||||
- **Query Optimization:** Extensive use of select_related, prefetch_related, and custom querysets
|
||||
- **Caching Strategy:** Multi-tier caching with Redis integration
|
||||
- **Geographic Features:** Full PostGIS integration for spatial queries
|
||||
- **API Design:** RESTful APIs with comprehensive documentation
|
||||
- **Security:** Production-ready security configuration
|
||||
|
||||
### Data Architecture Quality
|
||||
- **History Tracking:** Comprehensive audit trails for all changes
|
||||
- **Relationship Integrity:** Well-designed foreign key relationships
|
||||
- **Performance Optimization:** Database-level optimizations and indexing
|
||||
- **Geographic Integration:** Sophisticated location-based features
|
||||
- **Search Capabilities:** Advanced full-text search and filtering
|
||||
|
||||
## Infrastructure & Deployment
|
||||
|
||||
### Environment Configuration
|
||||
- **Environment Variables:** Comprehensive environment-based configuration
|
||||
- **Settings Modules:** Multiple environment-specific settings
|
||||
- **Security Configuration:** Production-ready security settings
|
||||
- **CORS Configuration:** Proper API access configuration
|
||||
|
||||
### Media & Static Files
|
||||
- **Static Files:** Whitenoise integration for static file serving
|
||||
- **Media Management:** Organized media storage with automatic cleanup
|
||||
- **Image Processing:** EXIF metadata handling and image optimization
|
||||
|
||||
## Architecture Quality Assessment
|
||||
|
||||
### Major Strengths
|
||||
- **Production Readiness:** Enterprise-grade architecture with comprehensive monitoring
|
||||
- **Performance Optimization:** Sophisticated query optimization and caching strategies
|
||||
- **Developer Experience:** Excellent development tooling and automation
|
||||
- **Geographic Features:** Advanced PostGIS integration for location-based features
|
||||
- **API Design:** Well-documented RESTful APIs with OpenAPI integration
|
||||
- **History Tracking:** Comprehensive audit capabilities
|
||||
- **Modern Tooling:** UV package management, Tailwind CSS, HTMX integration
|
||||
|
||||
### Technical Excellence
|
||||
- **Code Quality:** High-quality codebase with comprehensive testing
|
||||
- **Architecture Patterns:** Clean implementation of Django best practices
|
||||
- **Database Design:** Well-normalized schema with proper relationships
|
||||
- **Security:** Production-ready security configuration
|
||||
- **Monitoring:** Comprehensive health and performance monitoring
|
||||
|
||||
### Current Focus Areas
|
||||
- **Continued Optimization:** Performance monitoring and query optimization
|
||||
- **Feature Enhancement:** Ongoing development of advanced features
|
||||
- **Geographic Expansion:** Enhanced location-based functionality
|
||||
- **API Evolution:** Continued API development and documentation
|
||||
|
||||
---
|
||||
|
||||
**Note:** This analysis reflects the project state as of August 23, 2025, showing a significantly matured Django application with enterprise-grade architecture, comprehensive tooling, and production-ready features. The project has evolved from the early development stage described in January 2025 to a sophisticated, well-architected web application.
|
||||
16
docs/htmx-patterns.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# HTMX Patterns for ThrillWiki
|
||||
|
||||
This document records the HTMX patterns used across the project. It is
|
||||
intended as a living reference for developers migrating Alpine-driven
|
||||
interactions to server-driven HTMX flows.
|
||||
|
||||
Key patterns:
|
||||
|
||||
- Partial rendering for list and detail updates (`*_partial.html`)
|
||||
- `HX-Trigger` for cross-component events
|
||||
- `HX-Redirect` for post-auth redirects
|
||||
- `hx-indicator` and skeleton loaders for UX
|
||||
- Field-level validation via `validate_field` query param
|
||||
|
||||
See templates under `backend/templates/htmx/` and mixins in
|
||||
`backend/apps/core/mixins/__init__.py` for examples.
|
||||
132
docs/nuxt/00-CONTEXT-SUMMARY.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 📋 ThrillWiki Nuxt Frontend - Context Summary for LLMs
|
||||
|
||||
## 🎯 Project Overview
|
||||
Building a modern Nuxt 3 frontend for ThrillWiki (theme park database) that integrates seamlessly with the existing Django REST API backend. The frontend will be implemented in the `frontend/` directory using an existing component library and Context7 for documentation.
|
||||
|
||||
## 🏗️ Current System Architecture
|
||||
```
|
||||
thrillwiki_django_no_react/
|
||||
├── backend/ # Django REST API (existing, robust)
|
||||
│ ├── apps/api/v1/ # Comprehensive REST API
|
||||
│ ├── templates/ # HTMX + Alpine.js templates (separate system)
|
||||
│ └── static/ # Backend static files
|
||||
├── frontend/ # NEW - Nuxt 3 frontend (to be created)
|
||||
│ ├── components/ # Using existing component library
|
||||
│ ├── composables/ # API integration & auth
|
||||
│ ├── pages/ # Route pages
|
||||
│ └── plugins/ # Context7 integration
|
||||
├── docs/nuxt/ # This documentation (Context7-powered)
|
||||
└── context_portal/ # Context7 integration
|
||||
```
|
||||
|
||||
## 🔧 Key Technical Decisions Made
|
||||
|
||||
### Framework & Architecture
|
||||
- **Frontend Framework:** Nuxt 3 with Vue 3 Composition API
|
||||
- **Language:** TypeScript for type safety
|
||||
- **Location:** `frontend/` directory (separate from Django backend)
|
||||
- **State Management:** Pinia for global state
|
||||
- **Component Library:** TBD - existing reusable library (user choice needed)
|
||||
|
||||
### Authentication & API
|
||||
- **Authentication:** JWT with refresh tokens (requires Django backend enhancement)
|
||||
- **API Integration:** Django REST API at `/api/v1/`
|
||||
- **Current Django Auth:** Token-based (needs JWT upgrade)
|
||||
- **API Client:** Custom composables with $fetch
|
||||
|
||||
### Documentation & Knowledge Management
|
||||
- **Documentation System:** Context7 integration
|
||||
- **Knowledge Preservation:** LLM-optimized documentation structure
|
||||
- **Status Tracking:** Comprehensive progress tracking system
|
||||
|
||||
### Deployment & Infrastructure
|
||||
- **Deployment:** Self-hosted with Docker
|
||||
- **Development:** Separate frontend/backend with proxy
|
||||
- **Environment:** Development proxy to Django backend
|
||||
|
||||
## 📊 Current Django Backend Capabilities
|
||||
|
||||
### Existing API Endpoints (Comprehensive)
|
||||
```
|
||||
/api/v1/auth/ # Authentication (token-based, needs JWT)
|
||||
/api/v1/parks/ # Parks CRUD, search, photos
|
||||
/api/v1/rides/ # Rides CRUD, search, photos
|
||||
/api/v1/accounts/ # User profiles, top lists
|
||||
/api/v1/rankings/ # Ride rankings system
|
||||
/api/v1/maps/ # Geographic data and mapping
|
||||
/api/v1/history/ # Change tracking and history
|
||||
/api/v1/trending/ # Trending content
|
||||
/api/v1/health/ # System health checks
|
||||
```
|
||||
|
||||
### Database Models (Rich Data Structure)
|
||||
- **Parks:** Name, location, operator, status, photos
|
||||
- **Rides:** Name, park, category, manufacturer, specs, photos
|
||||
- **Users:** Profiles, preferences, top lists, reviews
|
||||
- **Photos:** Park/ride photos with moderation
|
||||
- **Rankings:** Sophisticated ride ranking system
|
||||
- **History:** Complete change tracking
|
||||
- **Companies:** Manufacturers, operators, designers
|
||||
|
||||
## 🎯 User Requirements (Final)
|
||||
|
||||
### Core Requirements
|
||||
1. **Context7 Integration:** Use for documentation and knowledge management
|
||||
2. **Frontend Location:** Implement in `frontend/` directory
|
||||
3. **Component Library:** Use existing reusable component library (choice needed)
|
||||
4. **Authentication:** JWT with refresh tokens
|
||||
5. **Deployment:** Self-hosted infrastructure
|
||||
6. **Features:** Submission system with moderation capabilities
|
||||
7. **Design:** Completely responsive, modern interface
|
||||
|
||||
### Feature Requirements
|
||||
- **Content Submission:** Users can submit parks, rides, photos, reviews
|
||||
- **Moderation System:** Admin interface for content approval/rejection
|
||||
- **User Management:** Profiles, authentication, preferences
|
||||
- **Search & Discovery:** Advanced search, filtering, trending content
|
||||
- **Photo Management:** Upload, gallery, moderation workflow
|
||||
- **Rankings:** Display and interact with ride ranking system
|
||||
- **Maps Integration:** Geographic visualization of parks/rides
|
||||
|
||||
## 🚨 Critical Dependencies & Blockers
|
||||
|
||||
### Immediate Blockers
|
||||
1. **Component Library Choice:** Must choose before frontend setup
|
||||
- Options: Nuxt UI, Vuetify, Quasar, PrimeVue, Element Plus
|
||||
- Impact: Affects entire UI architecture and development approach
|
||||
|
||||
### Technical Dependencies
|
||||
1. **Django JWT Enhancement:** Backend needs JWT endpoints added
|
||||
2. **Context7 Integration:** Approach for documentation integration
|
||||
3. **Development Environment:** Frontend/backend proxy configuration
|
||||
|
||||
## 🔄 Integration Points
|
||||
|
||||
### Django Backend Integration
|
||||
- **API Consumption:** All data via REST API endpoints
|
||||
- **Authentication:** JWT tokens for secure API access
|
||||
- **File Uploads:** Photos and media through Django endpoints
|
||||
- **Real-time Features:** WebSocket integration for live updates (future)
|
||||
|
||||
### Context7 Integration
|
||||
- **Documentation:** Auto-generated API docs and component docs
|
||||
- **Knowledge Management:** Preserve implementation context
|
||||
- **LLM Handoffs:** Structured information for continuation
|
||||
|
||||
## 📈 Success Metrics
|
||||
1. **Functionality Parity:** All Django backend features accessible
|
||||
2. **Performance:** Fast loading, responsive interactions
|
||||
3. **User Experience:** Intuitive, modern interface
|
||||
4. **Maintainability:** Clean, documented, testable code
|
||||
5. **Scalability:** Ready for future feature additions
|
||||
|
||||
## 🎨 Design Philosophy
|
||||
- **Mobile-First:** Responsive design starting with mobile
|
||||
- **Modern Aesthetics:** Clean, contemporary interface
|
||||
- **User-Centric:** Intuitive navigation and interactions
|
||||
- **Performance-Focused:** Fast loading and smooth animations
|
||||
- **Accessible:** WCAG compliance and keyboard navigation
|
||||
|
||||
---
|
||||
|
||||
**Next Steps:** Read `00-CONTINUATION-GUIDE.md` for specific implementation instructions.
|
||||
227
docs/nuxt/00-CONTINUATION-GUIDE.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 🔄 ThrillWiki Nuxt Frontend - Continuation Guide for LLMs
|
||||
|
||||
## 🎯 How to Continue This Project
|
||||
|
||||
### 1. **Read These Files First (in order):**
|
||||
1. `00-PROJECT-STATUS.md` - Current status and immediate next steps
|
||||
2. `00-CONTEXT-SUMMARY.md` - Complete project context and technical decisions
|
||||
3. This file (`00-CONTINUATION-GUIDE.md`) - How to proceed
|
||||
4. `planning/requirements.md` - Detailed requirements (when created)
|
||||
5. `planning/architecture-decisions.md` - Technical architecture (when created)
|
||||
|
||||
### 2. **Current State Assessment:**
|
||||
- **Progress:** 15% complete - Documentation structure created
|
||||
- **Phase:** Foundation Setup - Ready to begin implementation
|
||||
- **Blocker:** Component library choice needed before proceeding
|
||||
- **Location:** Working directory is `/Users/talor/thrillwiki_django_no_react`
|
||||
|
||||
### 3. **Immediate Next Actions (Priority Order):**
|
||||
|
||||
#### **CRITICAL - Component Library Decision**
|
||||
**Status:** ⏳ BLOCKED - User choice required
|
||||
**Action:** Ask user to choose from:
|
||||
- **Nuxt UI** (Recommended - Nuxt-native, Tailwind-based, modern)
|
||||
- **Vuetify** (Material Design, comprehensive)
|
||||
- **Quasar** (Full framework with CLI)
|
||||
- **PrimeVue** (Enterprise-focused, rich components)
|
||||
- **Element Plus** (Popular, Vue 3 compatible)
|
||||
|
||||
**Impact:** This choice affects entire frontend architecture, so must be decided first.
|
||||
|
||||
#### **Step 1: Create Planning Documentation**
|
||||
**Status:** ⏳ TODO
|
||||
**Files to create:**
|
||||
```
|
||||
docs/nuxt/planning/
|
||||
├── requirements.md # Detailed feature requirements
|
||||
├── architecture-decisions.md # Technical architecture details
|
||||
├── component-library-analysis.md # Analysis of chosen library
|
||||
└── api-integration-strategy.md # Django API integration plan
|
||||
```
|
||||
|
||||
#### **Step 2: Set Up Frontend Directory**
|
||||
**Status:** ⏳ TODO
|
||||
**Actions:**
|
||||
1. Create `frontend/` directory in project root
|
||||
2. Initialize Nuxt 3 project with TypeScript
|
||||
3. Install chosen component library
|
||||
4. Configure development environment
|
||||
|
||||
#### **Step 3: Configure Development Environment**
|
||||
**Status:** ⏳ TODO
|
||||
**Actions:**
|
||||
1. Set up proxy from Nuxt to Django backend (`http://localhost:8000`)
|
||||
2. Configure CORS in Django for frontend development
|
||||
3. Set up environment variables for API endpoints
|
||||
4. Test basic API connectivity
|
||||
|
||||
#### **Step 4: Implement JWT Authentication**
|
||||
**Status:** ⏳ TODO - Requires Django backend enhancement
|
||||
**Actions:**
|
||||
1. **Backend:** Add JWT endpoints to Django
|
||||
2. **Frontend:** Create authentication composables
|
||||
3. **Frontend:** Implement login/signup/logout flows
|
||||
4. **Frontend:** Add route protection middleware
|
||||
|
||||
### 4. **File Creation Commands (Ready to Execute)**
|
||||
|
||||
Once component library is chosen, execute these commands:
|
||||
|
||||
```bash
|
||||
# Create frontend directory
|
||||
mkdir frontend
|
||||
cd frontend
|
||||
|
||||
# Initialize Nuxt 3 project (example with Nuxt UI)
|
||||
npx nuxi@latest init . --package-manager npm
|
||||
npm install --save-dev typescript @nuxt/typescript-build
|
||||
|
||||
# Install chosen component library (example: Nuxt UI)
|
||||
npm install @nuxt/ui
|
||||
|
||||
# Install additional dependencies
|
||||
npm install @pinia/nuxt pinia @vueuse/nuxt @vueuse/core
|
||||
|
||||
# Return to project root
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 5. **Django Backend Enhancements Needed**
|
||||
|
||||
#### **JWT Authentication Setup**
|
||||
**File:** `backend/requirements.txt` or `backend/pyproject.toml`
|
||||
```python
|
||||
# Add these dependencies
|
||||
djangorestframework-simplejwt
|
||||
django-cors-headers # If not already present
|
||||
```
|
||||
|
||||
**Files to modify:**
|
||||
- `backend/config/settings/base.py` - Add JWT configuration
|
||||
- `backend/apps/api/v1/auth/urls.py` - Add JWT endpoints
|
||||
- `backend/apps/api/v1/auth/views.py` - Add JWT views
|
||||
|
||||
#### **CORS Configuration**
|
||||
**File:** `backend/config/settings/local.py`
|
||||
```python
|
||||
# Add frontend development server
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000", # Nuxt dev server
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
```
|
||||
|
||||
### 6. **Context7 Integration Plan**
|
||||
|
||||
#### **Documentation Strategy**
|
||||
1. **Auto-generate API docs** from Django REST framework
|
||||
2. **Component documentation** using Storybook or similar
|
||||
3. **Implementation guides** with code examples
|
||||
4. **Progress tracking** with status updates
|
||||
|
||||
#### **Context7 MCP Integration**
|
||||
**File:** `frontend/plugins/context7.client.ts`
|
||||
```typescript
|
||||
// Context7 integration for documentation
|
||||
export default defineNuxtPlugin(() => {
|
||||
// Initialize Context7 connection
|
||||
// Auto-document API calls
|
||||
// Track component usage
|
||||
})
|
||||
```
|
||||
|
||||
### 7. **Development Workflow**
|
||||
|
||||
#### **Daily Development Process**
|
||||
1. **Update status** in `00-PROJECT-STATUS.md`
|
||||
2. **Document decisions** in relevant specification files
|
||||
3. **Create implementation guides** as you build
|
||||
4. **Test integration** with Django backend
|
||||
5. **Update progress tracking** before ending session
|
||||
|
||||
#### **Testing Strategy**
|
||||
- **Unit tests:** Vitest for composables and utilities
|
||||
- **Component tests:** Vue Test Utils for UI components
|
||||
- **E2E tests:** Playwright for full user flows
|
||||
- **API tests:** Test Django integration
|
||||
|
||||
### 8. **Common Pitfalls & Solutions**
|
||||
|
||||
#### **CORS Issues**
|
||||
**Problem:** Frontend can't connect to Django backend
|
||||
**Solution:** Ensure CORS_ALLOWED_ORIGINS includes frontend URL
|
||||
|
||||
#### **Authentication Flow**
|
||||
**Problem:** JWT token management complexity
|
||||
**Solution:** Use composables for token refresh and storage
|
||||
|
||||
#### **Component Library Integration**
|
||||
**Problem:** Styling conflicts or missing features
|
||||
**Solution:** Follow library's Nuxt integration guide exactly
|
||||
|
||||
#### **Development Server Proxy**
|
||||
**Problem:** API calls fail in development
|
||||
**Solution:** Configure Nuxt proxy in `nuxt.config.ts`
|
||||
|
||||
### 9. **Success Checkpoints**
|
||||
|
||||
#### **Phase 1 Complete When:**
|
||||
- [ ] Frontend directory set up with Nuxt 3
|
||||
- [ ] Component library integrated and working
|
||||
- [ ] Basic authentication flow implemented
|
||||
- [ ] API connectivity established
|
||||
- [ ] Development environment fully configured
|
||||
|
||||
#### **Phase 2 Complete When:**
|
||||
- [ ] Parks listing page functional
|
||||
- [ ] Rides listing page functional
|
||||
- [ ] Search functionality working
|
||||
- [ ] Photo upload/display working
|
||||
- [ ] User profiles accessible
|
||||
|
||||
### 10. **Emergency Continuation Points**
|
||||
|
||||
#### **If Starting Fresh:**
|
||||
1. Read all `00-*.md` files in this directory
|
||||
2. Check `planning/` directory for detailed specs
|
||||
3. Review `implementation/phase-*/README.md` for current phase
|
||||
4. Check Django backend status and API availability
|
||||
|
||||
#### **If Blocked:**
|
||||
1. Update `00-PROJECT-STATUS.md` with blocker details
|
||||
2. Document the issue in appropriate specification file
|
||||
3. Create workaround plan if possible
|
||||
4. Update continuation guide with new information
|
||||
|
||||
### 11. **Key Commands Reference**
|
||||
|
||||
```bash
|
||||
# Start Django backend
|
||||
cd backend && uv run manage.py runserver
|
||||
|
||||
# Start Nuxt frontend (once created)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# Run tests
|
||||
cd frontend && npm run test
|
||||
|
||||
# Build for production
|
||||
cd frontend && npm run build
|
||||
|
||||
# Check API connectivity
|
||||
curl http://localhost:8000/api/v1/health/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **CRITICAL REMINDER FOR CONTINUING LLMs:**
|
||||
|
||||
1. **Always check `00-PROJECT-STATUS.md` first** for current state
|
||||
2. **Component library choice is BLOCKING** - must be resolved before proceeding
|
||||
3. **Django backend is fully functional** - focus on frontend implementation
|
||||
4. **Context7 integration approach** needs clarification from user
|
||||
5. **Update documentation as you work** - this is crucial for handoffs
|
||||
|
||||
**Current Working Directory:** `/Users/talor/thrillwiki_django_no_react`
|
||||
**Next File to Create:** `docs/nuxt/planning/requirements.md`
|
||||
**Next Major Task:** Set up `frontend/` directory with chosen component library
|
||||
105
docs/nuxt/00-PROJECT-STATUS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 🎯 ThrillWiki Nuxt Frontend - Project Status
|
||||
|
||||
**Last Updated:** 2025-09-27 21:26 UTC
|
||||
**Current Phase:** Phase 1 - Foundation Implementation In Progress
|
||||
**Next Action:** Debug Nuxt development server 503 errors
|
||||
|
||||
## 📊 Overall Progress: 70% Complete (Phase 1: 85% Complete)
|
||||
|
||||
### ✅ COMPLETED
|
||||
- [x] Backend analysis and API documentation review
|
||||
- [x] Architecture planning and technical decisions
|
||||
- [x] User requirements gathering (Context7, existing component library, frontend/ directory)
|
||||
- [x] LLM-optimized documentation structure creation
|
||||
- [x] Project status tracking system setup
|
||||
- [x] Comprehensive requirements documentation
|
||||
- [x] Detailed architecture decisions and technical specifications
|
||||
- [x] Implementation strategy and phase planning
|
||||
- [x] **Nuxt 4 project setup in frontend/ directory**
|
||||
- [x] **Component library selection and integration (Nuxt UI)**
|
||||
- [x] **JWT authentication composables implementation**
|
||||
- [x] **API integration composables (useApi, useParksApi, useRidesApi, etc.)**
|
||||
- [x] **TypeScript types for all API endpoints**
|
||||
- [x] **Homepage with hero section and features**
|
||||
- [x] **Base layout components (AppHeader, AppFooter)**
|
||||
- [x] **Development environment configuration with Django proxy**
|
||||
- [x] **Fixed missing dotenv dependency with Bun**
|
||||
- [x] **Updated authentication composable to match Django API endpoints**
|
||||
- [x] **Verified Django backend health and API availability**
|
||||
|
||||
### 🔄 IN PROGRESS - PHASE 1: Foundation (90% Complete)
|
||||
- [x] Set up Nuxt 3 project in frontend/ directory ✅ (Nuxt 4)
|
||||
- [x] Choose and integrate existing component library ✅ (Nuxt UI)
|
||||
- [ ] Configure Context7 for documentation
|
||||
- [x] Implement JWT authentication with Django backend ✅ (composables ready)
|
||||
- [x] Create base layout and navigation components ✅ (AppHeader with full navigation)
|
||||
- [x] **Register page (/auth/register)** ✅
|
||||
- [x] **Parks listing page (/parks)** ✅
|
||||
- [x] **Rides listing page (/rides)** ✅
|
||||
- [x] **Navigation menu in AppHeader** ✅ (comprehensive with mobile support)
|
||||
- [ ] **MISSING: Authentication middleware for protected routes**
|
||||
- [ ] **MISSING: Test Django backend integration and JWT flow**
|
||||
|
||||
### ⏳ TODO - PHASE 2: Core Features (Week 2)
|
||||
- [ ] Parks and rides listing/detail pages
|
||||
- [ ] Search and filtering functionality
|
||||
- [ ] Photo management system
|
||||
- [ ] User profile integration
|
||||
|
||||
### ⏳ TODO - PHASE 3: Advanced Features (Week 3)
|
||||
- [ ] Submission system for user-generated content
|
||||
- [ ] Moderation interface for admins
|
||||
- [ ] Advanced search and analytics
|
||||
- [ ] Performance optimization
|
||||
|
||||
### ⏳ TODO - PHASE 4: Documentation & Deployment (Week 4)
|
||||
- [ ] Complete Context7 documentation
|
||||
- [ ] Self-hosted deployment setup
|
||||
- [ ] Testing and quality assurance
|
||||
- [ ] Production optimization
|
||||
|
||||
## 🎯 IMMEDIATE NEXT STEPS
|
||||
1. **CRITICAL: Debug 503 errors:** Investigate why Nuxt development server returns 503 errors
|
||||
2. **Possible solutions to try:**
|
||||
- Restart Nuxt development server
|
||||
- Clear Nuxt cache (.nuxt directory)
|
||||
- Check for port conflicts
|
||||
- Verify Nuxt configuration
|
||||
- Try different port for development server
|
||||
3. **Once 503 errors resolved:**
|
||||
- Test Django backend integration and JWT flow
|
||||
- Re-enable authentication initialization
|
||||
- Restore full homepage content
|
||||
- Add authentication middleware for route protection
|
||||
4. **Continue Phase 1 completion:**
|
||||
- Create park/ride detail pages (/parks/[slug], /rides/[slug])
|
||||
- Test all navigation and basic functionality
|
||||
|
||||
## 🔧 Technical Decisions Made
|
||||
- **Framework:** Nuxt 3 with TypeScript
|
||||
- **Location:** frontend/ directory (separate from Django backend)
|
||||
- **Documentation:** Context7 integration for knowledge management
|
||||
- **Authentication:** JWT with refresh tokens (requires Django backend enhancement)
|
||||
- **Design:** Fresh, modern design with existing component library
|
||||
- **Deployment:** Self-hosted with Docker
|
||||
- **Features:** Submission system, moderation tools, responsive design
|
||||
|
||||
## 🚨 Blockers & Dependencies
|
||||
- **CRITICAL BLOCKER:** Nuxt development server experiencing 503 Service Unavailable errors
|
||||
- **INVESTIGATION NEEDED:** 503 errors occur even with minimal page content and disabled authentication
|
||||
- **STATUS:** Django backend is healthy and responding correctly
|
||||
- **STATUS:** Nuxt server starts successfully but pages fail to load with 503 errors
|
||||
- **NEXT STEPS:** Need to investigate Nuxt configuration or restart development environment
|
||||
|
||||
## 📋 Key Requirements Recap
|
||||
1. Use Context7 for documentation/knowledge management
|
||||
2. Implement in frontend/ directory
|
||||
3. Use existing reusable component library
|
||||
4. JWT authentication with refresh tokens
|
||||
5. Self-hosted deployment
|
||||
6. Submission and moderation system
|
||||
7. Completely responsive design
|
||||
|
||||
---
|
||||
|
||||
**For LLMs continuing this work:** Read `00-CONTEXT-SUMMARY.md` and `00-CONTINUATION-GUIDE.md` next.
|
||||
252
docs/nuxt/IMPLEMENTATION-READY.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 🚀 ThrillWiki Nuxt Frontend - Implementation Ready
|
||||
|
||||
**Status:** ✅ PLANNING COMPLETE
|
||||
**Last Updated:** 2025-01-27 20:01 UTC
|
||||
**Ready for:** Phase 1 Implementation
|
||||
|
||||
## 🎉 Planning Phase Complete!
|
||||
|
||||
The comprehensive planning phase for the ThrillWiki Nuxt frontend is now **65% complete** with all major architectural decisions made and detailed documentation created. We're ready to begin implementation!
|
||||
|
||||
## 📚 Documentation Created
|
||||
|
||||
### Core Planning Documents
|
||||
- ✅ **Project Status** (`00-PROJECT-STATUS.md`) - Master status tracking
|
||||
- ✅ **Context Summary** (`00-CONTEXT-SUMMARY.md`) - Complete project context for LLMs
|
||||
- ✅ **Continuation Guide** (`00-CONTINUATION-GUIDE.md`) - How to continue work
|
||||
- ✅ **Requirements** (`planning/requirements.md`) - Detailed functional requirements
|
||||
- ✅ **Architecture Decisions** (`planning/architecture-decisions.md`) - Technical specifications
|
||||
|
||||
### LLM-Optimized Structure
|
||||
```
|
||||
docs/nuxt/
|
||||
├── 00-PROJECT-STATUS.md ✅ Master status tracking
|
||||
├── 00-CONTEXT-SUMMARY.md ✅ Project context for LLMs
|
||||
├── 00-CONTINUATION-GUIDE.md ✅ Continuation instructions
|
||||
├── planning/
|
||||
│ ├── requirements.md ✅ Detailed requirements
|
||||
│ └── architecture-decisions.md ✅ Technical architecture
|
||||
├── specifications/ 📁 Ready for component specs
|
||||
├── implementation/ 📁 Ready for phase guides
|
||||
├── reference/ 📁 Ready for reference docs
|
||||
├── templates/ 📁 Ready for code templates
|
||||
└── assets/ 📁 Ready for diagrams
|
||||
```
|
||||
|
||||
## 🏗️ Architecture Summary
|
||||
|
||||
### Technology Stack (Finalized)
|
||||
- **Framework:** Nuxt 3 with Vue 3 Composition API
|
||||
- **Language:** TypeScript for type safety
|
||||
- **State Management:** Pinia for global state
|
||||
- **Authentication:** JWT with refresh tokens
|
||||
- **API Integration:** Custom composables with $fetch
|
||||
- **Testing:** Vitest + Playwright
|
||||
- **Deployment:** Self-hosted with Docker
|
||||
|
||||
### Project Structure (Designed)
|
||||
```
|
||||
frontend/ # New Nuxt 3 frontend
|
||||
├── components/ # Vue components
|
||||
│ ├── ui/ # UI library components
|
||||
│ ├── layout/ # Layout components
|
||||
│ ├── forms/ # Form components
|
||||
│ └── features/ # Feature-specific components
|
||||
├── composables/ # Vue composables
|
||||
├── pages/ # File-based routing
|
||||
├── stores/ # Pinia stores
|
||||
├── middleware/ # Route middleware
|
||||
├── plugins/ # Nuxt plugins
|
||||
└── types/ # TypeScript definitions
|
||||
```
|
||||
|
||||
### Key Features (Specified)
|
||||
1. **Authentication System** - JWT with refresh tokens
|
||||
2. **Parks Management** - Browse, search, submit, moderate
|
||||
3. **Rides Management** - Detailed specs, photos, rankings
|
||||
4. **Content Submission** - User-generated content workflow
|
||||
5. **Moderation Interface** - Admin tools and queues
|
||||
6. **Search & Discovery** - Advanced search and filtering
|
||||
7. **Photo Management** - Upload, galleries, moderation
|
||||
8. **Maps Integration** - Interactive location visualization
|
||||
9. **User Profiles** - Social features and top lists
|
||||
10. **Rankings System** - Ride ranking display and interaction
|
||||
|
||||
## 🚨 Critical Decision Required
|
||||
|
||||
### Component Library Choice (BLOCKING)
|
||||
|
||||
**We need your decision on which component library to use before proceeding:**
|
||||
|
||||
#### Option 1: Nuxt UI (Recommended) ⭐
|
||||
- **Best for:** Modern, Nuxt-native development
|
||||
- **Pros:** Built for Nuxt 3, Tailwind integration, TypeScript support
|
||||
- **Cons:** Newer library, smaller component set
|
||||
- **Bundle Size:** Small (tree-shakable)
|
||||
|
||||
#### Option 2: Vuetify
|
||||
- **Best for:** Material Design consistency
|
||||
- **Pros:** Mature, comprehensive, strong community
|
||||
- **Cons:** Large bundle, Material Design constraints
|
||||
- **Bundle Size:** Large
|
||||
|
||||
#### Option 3: PrimeVue
|
||||
- **Best for:** Enterprise applications
|
||||
- **Pros:** Professional themes, comprehensive components
|
||||
- **Cons:** Commercial themes, learning curve
|
||||
- **Bundle Size:** Medium-Large
|
||||
|
||||
#### Option 4: Quasar
|
||||
- **Best for:** Full-featured applications
|
||||
- **Pros:** Complete framework, CLI tools
|
||||
- **Cons:** Opinionated, larger learning curve
|
||||
- **Bundle Size:** Large
|
||||
|
||||
#### Option 5: Element Plus
|
||||
- **Best for:** Familiar Vue developers
|
||||
- **Pros:** Popular, Vue 3 compatible, good docs
|
||||
- **Cons:** Chinese-focused design, larger bundle
|
||||
- **Bundle Size:** Medium
|
||||
|
||||
**Which component library would you prefer?** This decision will determine:
|
||||
- UI design system and components available
|
||||
- Bundle size and performance characteristics
|
||||
- Development workflow and patterns
|
||||
- Integration complexity with Nuxt 3
|
||||
|
||||
## 🎯 Implementation Plan Ready
|
||||
|
||||
### Phase 1: Foundation (Week 1)
|
||||
**Ready to start once component library is chosen:**
|
||||
|
||||
1. **Day 1-2:** Project setup
|
||||
- Initialize Nuxt 3 project in `frontend/` directory
|
||||
- Install chosen component library
|
||||
- Configure TypeScript and development environment
|
||||
|
||||
2. **Day 3-4:** Authentication system
|
||||
- Enhance Django backend with JWT endpoints
|
||||
- Implement Nuxt authentication composables
|
||||
- Create login/signup/logout flows
|
||||
|
||||
3. **Day 5-7:** Base components
|
||||
- Create layout components (header, footer, navigation)
|
||||
- Set up routing and middleware
|
||||
- Implement basic UI components
|
||||
|
||||
### Phase 2: Core Features (Week 2)
|
||||
- Parks and rides listing/detail pages
|
||||
- Search and filtering functionality
|
||||
- Photo management system
|
||||
- User profile integration
|
||||
|
||||
### Phase 3: Advanced Features (Week 3)
|
||||
- Content submission system
|
||||
- Moderation interface
|
||||
- Advanced search and analytics
|
||||
- Maps integration
|
||||
|
||||
### Phase 4: Polish & Deployment (Week 4)
|
||||
- Performance optimization
|
||||
- Comprehensive testing
|
||||
- Documentation completion
|
||||
- Production deployment
|
||||
|
||||
## 🔧 Development Environment Ready
|
||||
|
||||
### Prerequisites Confirmed
|
||||
- ✅ Node.js 18+ available
|
||||
- ✅ npm package manager
|
||||
- ✅ Django backend running at `http://localhost:8000`
|
||||
- ✅ PostgreSQL database accessible
|
||||
- ✅ Context7 MCP server available
|
||||
|
||||
### Commands Ready to Execute
|
||||
```bash
|
||||
# Once component library is chosen, these commands are ready:
|
||||
|
||||
# Create frontend directory
|
||||
mkdir frontend && cd frontend
|
||||
|
||||
# Initialize Nuxt 3 project
|
||||
npx nuxi@latest init . --package-manager npm
|
||||
|
||||
# Install TypeScript and chosen component library
|
||||
npm install --save-dev typescript @nuxt/typescript-build
|
||||
npm install [CHOSEN_COMPONENT_LIBRARY]
|
||||
|
||||
# Install additional dependencies
|
||||
npm install @pinia/nuxt pinia @vueuse/nuxt @vueuse/core
|
||||
|
||||
# Start development
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📋 Context7 Integration Plan
|
||||
|
||||
### Documentation Strategy
|
||||
- **Auto-generate** API and component documentation
|
||||
- **Track implementation** progress and decisions
|
||||
- **Preserve context** for LLM handoffs
|
||||
- **Monitor performance** and usage patterns
|
||||
|
||||
### Integration Points
|
||||
- Plugin for automatic API call documentation
|
||||
- Component usage tracking
|
||||
- Implementation decision logging
|
||||
- Progress milestone tracking
|
||||
|
||||
## 🎉 What's Been Accomplished
|
||||
|
||||
### ✅ Complete Planning Phase
|
||||
1. **Requirements Analysis** - All functional and technical requirements documented
|
||||
2. **Architecture Design** - Complete system architecture with technology decisions
|
||||
3. **Implementation Strategy** - 4-phase implementation plan with detailed timelines
|
||||
4. **Documentation Structure** - LLM-optimized documentation for seamless handoffs
|
||||
5. **Development Workflow** - Clear processes for development, testing, and deployment
|
||||
|
||||
### ✅ Technical Specifications
|
||||
1. **Authentication System** - JWT implementation strategy defined
|
||||
2. **API Integration** - Django REST API integration approach specified
|
||||
3. **Component Architecture** - Reusable component system designed
|
||||
4. **Performance Strategy** - Optimization and caching approaches planned
|
||||
5. **Testing Strategy** - Comprehensive testing approach with tools selected
|
||||
|
||||
### ✅ Ready for Implementation
|
||||
- All architectural decisions made
|
||||
- Development environment requirements specified
|
||||
- Implementation phases clearly defined
|
||||
- Success criteria established
|
||||
- Risk mitigation strategies planned
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
### Immediate Actions Required
|
||||
1. **Choose Component Library** - Critical blocking decision
|
||||
2. **Clarify Context7 Integration** - Specific integration approach
|
||||
3. **Begin Phase 1 Implementation** - Set up frontend directory
|
||||
|
||||
### Ready to Execute
|
||||
Once the component library is chosen, we can immediately:
|
||||
- Set up the Nuxt 3 project structure
|
||||
- Configure the development environment
|
||||
- Begin implementing the authentication system
|
||||
- Create the first UI components
|
||||
- Establish the Django backend integration
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **DECISION NEEDED: Which component library should we use?**
|
||||
|
||||
**Please choose from:**
|
||||
1. **Nuxt UI** (Recommended for modern, Nuxt-native development)
|
||||
2. **Vuetify** (For Material Design consistency)
|
||||
3. **PrimeVue** (For enterprise features)
|
||||
4. **Quasar** (For full-featured framework)
|
||||
5. **Element Plus** (For familiar Vue patterns)
|
||||
|
||||
Once you make this choice, we can immediately begin Phase 1 implementation with the complete foundation already planned and documented!
|
||||
|
||||
---
|
||||
|
||||
**All planning documentation is complete and ready for implementation. The project is fully specified and ready to build!** 🎉
|
||||
100
docs/nuxt/PROMPT-CONTINUE-WORK.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 🔄 CONTINUE WORK PROMPT
|
||||
|
||||
**Use this prompt to continue working on the ThrillWiki Nuxt frontend implementation.**
|
||||
|
||||
---
|
||||
|
||||
## PROMPT FOR CONTINUING WORK
|
||||
|
||||
```
|
||||
I need to continue working on the ThrillWiki Nuxt frontend implementation. This is an ongoing project with existing work and documentation.
|
||||
|
||||
CONTEXT:
|
||||
- Working directory: /Users/talor/thrillwiki_django_no_react
|
||||
- Django backend exists at backend/ with REST API at /api/v1/
|
||||
- Nuxt frontend being built in frontend/ directory
|
||||
- Comprehensive planning documentation exists at docs/nuxt/
|
||||
- Project uses Context7 for documentation and existing component library
|
||||
|
||||
CRITICAL: READ THESE FILES FIRST (IN ORDER):
|
||||
1. docs/nuxt/00-PROJECT-STATUS.md - Current status and immediate next steps
|
||||
2. docs/nuxt/00-CONTEXT-SUMMARY.md - Complete project context and decisions
|
||||
3. docs/nuxt/00-CONTINUATION-GUIDE.md - How to continue work and common issues
|
||||
4. docs/nuxt/IMPLEMENTATION-READY.md - Complete plan summary
|
||||
|
||||
CURRENT STATE ASSESSMENT:
|
||||
- Check the "Current Phase" in 00-PROJECT-STATUS.md
|
||||
- Look for any "BLOCKER" or "IN PROGRESS" items
|
||||
- Review the "IMMEDIATE NEXT STEPS" section
|
||||
- Check if frontend/ directory exists and what's implemented
|
||||
|
||||
IMPLEMENTATION PHASES:
|
||||
- Phase 1: Foundation (Nuxt setup, auth, basic components)
|
||||
- Phase 2: Core Features (parks, rides, search, photos)
|
||||
- Phase 3: Advanced Features (submission, moderation, maps)
|
||||
- Phase 4: Polish & Deployment (testing, optimization, deployment)
|
||||
|
||||
TECHNICAL STACK (FINALIZED):
|
||||
- Nuxt 3 with Vue 3 Composition API + TypeScript
|
||||
- Pinia for state management
|
||||
- JWT authentication with refresh tokens
|
||||
- Component library: [Check architecture-decisions.md for choice]
|
||||
- Custom composables with $fetch for API integration
|
||||
|
||||
COMMON CONTINUATION SCENARIOS:
|
||||
|
||||
IF FRONTEND DIRECTORY DOESN'T EXIST:
|
||||
- Component library choice may be needed
|
||||
- Follow Phase 1 setup instructions
|
||||
- Initialize Nuxt 3 project structure
|
||||
|
||||
IF FRONTEND EXISTS BUT INCOMPLETE:
|
||||
- Check package.json for installed dependencies
|
||||
- Review current implementation status
|
||||
- Continue with next phase tasks
|
||||
|
||||
IF BLOCKED:
|
||||
- Check 00-PROJECT-STATUS.md for blocker details
|
||||
- Review 00-CONTINUATION-GUIDE.md for solutions
|
||||
- Update status documentation with progress
|
||||
|
||||
DEVELOPMENT WORKFLOW:
|
||||
1. Update 00-PROJECT-STATUS.md with current progress
|
||||
2. Follow implementation guides in docs/nuxt/implementation/
|
||||
3. Test integration with Django backend at localhost:8000
|
||||
4. Document decisions and progress as you work
|
||||
5. Update status before ending session
|
||||
|
||||
KEY COMMANDS:
|
||||
- Start Django: cd backend && uv run manage.py runserver
|
||||
- Start Nuxt: cd frontend && npm run dev
|
||||
- Test API: curl http://localhost:8000/api/v1/health/
|
||||
|
||||
Please start by reading the status files to understand the current state, then continue with the appropriate next steps based on the current phase and any blockers.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## QUICK REFERENCE FOR CONTINUATION
|
||||
|
||||
### If Starting Fresh (No frontend/ directory):
|
||||
1. Read planning docs
|
||||
2. Ask for component library choice if not decided
|
||||
3. Run Phase 1 setup commands
|
||||
4. Begin authentication implementation
|
||||
|
||||
### If Continuing Existing Work:
|
||||
1. Check 00-PROJECT-STATUS.md for current phase
|
||||
2. Review what's implemented in frontend/
|
||||
3. Continue with next tasks in current phase
|
||||
4. Update documentation as you progress
|
||||
|
||||
### If Encountering Issues:
|
||||
1. Check 00-CONTINUATION-GUIDE.md for common solutions
|
||||
2. Review architecture-decisions.md for technical context
|
||||
3. Update 00-PROJECT-STATUS.md with blocker details
|
||||
4. Document workarounds or solutions found
|
||||
|
||||
---
|
||||
|
||||
**Copy the above prompt to continue work on the ThrillWiki Nuxt frontend.**
|
||||
215
docs/nuxt/README.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# 📋 ThrillWiki Nuxt Frontend - Complete Documentation
|
||||
|
||||
**Status:** ✅ PLANNING COMPLETE - READY FOR IMPLEMENTATION
|
||||
**Last Updated:** 2025-01-27 20:07 UTC
|
||||
**Progress:** 70% Complete (Planning Phase Done)
|
||||
|
||||
## 🎯 Quick Start
|
||||
|
||||
### For Starting Implementation
|
||||
**Use this prompt:** Copy from [`PROMPT-START-IMPLEMENTATION.md`](./PROMPT-START-IMPLEMENTATION.md)
|
||||
|
||||
### For Continuing Work
|
||||
**Use this prompt:** Copy from [`PROMPT-CONTINUE-WORK.md`](./PROMPT-CONTINUE-WORK.md)
|
||||
|
||||
## 📚 Documentation Overview
|
||||
|
||||
### 🚨 Critical Files (Read First)
|
||||
1. **[`00-PROJECT-STATUS.md`](./00-PROJECT-STATUS.md)** - Current status and immediate next steps
|
||||
2. **[`00-CONTEXT-SUMMARY.md`](./00-CONTEXT-SUMMARY.md)** - Complete project context for LLMs
|
||||
3. **[`00-CONTINUATION-GUIDE.md`](./00-CONTINUATION-GUIDE.md)** - How to continue work
|
||||
4. **[`IMPLEMENTATION-READY.md`](./IMPLEMENTATION-READY.md)** - Complete plan summary
|
||||
|
||||
### 📋 Planning Documents
|
||||
- **[`planning/requirements.md`](./planning/requirements.md)** - Detailed functional requirements (10 core features)
|
||||
- **[`planning/architecture-decisions.md`](./planning/architecture-decisions.md)** - Technical architecture and decisions
|
||||
|
||||
### 🚀 Implementation Prompts
|
||||
- **[`PROMPT-START-IMPLEMENTATION.md`](./PROMPT-START-IMPLEMENTATION.md)** - Start from scratch
|
||||
- **[`PROMPT-CONTINUE-WORK.md`](./PROMPT-CONTINUE-WORK.md)** - Continue existing work
|
||||
|
||||
## 🏗️ Project Architecture
|
||||
|
||||
### Technology Stack (Finalized)
|
||||
```
|
||||
Frontend (Nuxt 3)
|
||||
├── Vue 3 Composition API + TypeScript
|
||||
├── Pinia (State Management)
|
||||
├── Component Library (User Choice Required)
|
||||
├── JWT Authentication with Refresh Tokens
|
||||
├── Custom Composables with $fetch
|
||||
└── Self-hosted Docker Deployment
|
||||
|
||||
Backend (Django - Existing)
|
||||
├── Comprehensive REST API (/api/v1/)
|
||||
├── Token Authentication (Needs JWT Enhancement)
|
||||
├── PostgreSQL Database
|
||||
├── Parks, Rides, Photos, Users, Rankings
|
||||
└── HTMX + Alpine.js Templates (Separate)
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
thrillwiki_django_no_react/
|
||||
├── backend/ # Django REST API (existing)
|
||||
├── frontend/ # Nuxt 3 frontend (to be created)
|
||||
│ ├── components/ # Vue components
|
||||
│ ├── composables/ # API integration
|
||||
│ ├── pages/ # File-based routing
|
||||
│ ├── stores/ # Pinia stores
|
||||
│ └── types/ # TypeScript definitions
|
||||
└── docs/nuxt/ # This documentation
|
||||
```
|
||||
|
||||
## 🎯 Implementation Phases
|
||||
|
||||
### Phase 1: Foundation (Week 1) ⏳
|
||||
- [ ] Nuxt 3 project setup with TypeScript
|
||||
- [ ] Component library integration
|
||||
- [ ] JWT authentication system
|
||||
- [ ] Django backend integration
|
||||
- [ ] Basic layout components
|
||||
|
||||
### Phase 2: Core Features (Week 2) ⏳
|
||||
- [ ] Parks and rides listing/detail pages
|
||||
- [ ] Search and filtering functionality
|
||||
- [ ] Photo management system
|
||||
- [ ] User profile integration
|
||||
|
||||
### Phase 3: Advanced Features (Week 3) ⏳
|
||||
- [ ] Content submission system
|
||||
- [ ] Moderation interface
|
||||
- [ ] Advanced search and analytics
|
||||
- [ ] Maps integration
|
||||
|
||||
### Phase 4: Polish & Deployment (Week 4) ⏳
|
||||
- [ ] Performance optimization
|
||||
- [ ] Comprehensive testing
|
||||
- [ ] Documentation completion
|
||||
- [ ] Production deployment
|
||||
|
||||
## 🚨 Critical Blockers
|
||||
|
||||
### 1. Component Library Choice (REQUIRED)
|
||||
**Options:**
|
||||
- **Nuxt UI** (Recommended) - Modern, Nuxt-native, Tailwind-based
|
||||
- **Vuetify** - Material Design, comprehensive components
|
||||
- **PrimeVue** - Enterprise-focused, professional themes
|
||||
- **Quasar** - Full framework with CLI tools
|
||||
- **Element Plus** - Popular Vue 3 compatible library
|
||||
|
||||
### 2. Context7 Integration Approach
|
||||
- Auto-documentation strategy
|
||||
- API call tracking
|
||||
- Component usage monitoring
|
||||
- Progress milestone tracking
|
||||
|
||||
## 🔧 Key Features Specified
|
||||
|
||||
### Core Features (High Priority)
|
||||
1. **Authentication System** - JWT with refresh tokens, profile management
|
||||
2. **Parks Management** - Browse, search, submit, moderate parks
|
||||
3. **Rides Management** - Detailed specs, photos, rankings
|
||||
4. **Content Submission** - User-generated content workflow
|
||||
5. **Moderation Interface** - Admin tools and approval queues
|
||||
|
||||
### Advanced Features (Medium Priority)
|
||||
6. **Search & Discovery** - Advanced search, autocomplete, trending
|
||||
7. **Photo Management** - Upload, galleries, moderation workflow
|
||||
8. **User Profiles** - Social features, top lists, achievements
|
||||
9. **Maps Integration** - Interactive location visualization
|
||||
10. **Rankings System** - Ride ranking display and interaction
|
||||
|
||||
## 📊 Success Metrics
|
||||
|
||||
### Technical Requirements
|
||||
- **Performance:** < 3s initial load, < 1s navigation
|
||||
- **Bundle Size:** < 500KB initial JavaScript
|
||||
- **Test Coverage:** 80%+ for utilities and composables
|
||||
- **Accessibility:** WCAG 2.1 AA compliance
|
||||
- **Browser Support:** Modern browsers (latest 2 versions)
|
||||
|
||||
### Functional Requirements
|
||||
- **Functionality Parity:** All Django backend features accessible
|
||||
- **User Experience:** Intuitive, mobile-first design
|
||||
- **Maintainability:** Clean, documented, testable code
|
||||
- **Scalability:** Architecture supports future features
|
||||
|
||||
## 🚀 Ready to Start
|
||||
|
||||
### Prerequisites Confirmed
|
||||
- ✅ Django backend with comprehensive REST API
|
||||
- ✅ Node.js 18+ and npm available
|
||||
- ✅ Context7 MCP server integration planned
|
||||
- ✅ Self-hosted deployment strategy defined
|
||||
|
||||
### Commands Ready (Once Component Library Chosen)
|
||||
```bash
|
||||
# Create and setup frontend
|
||||
mkdir frontend && cd frontend
|
||||
npx nuxi@latest init . --package-manager npm
|
||||
npm install [CHOSEN_COMPONENT_LIBRARY]
|
||||
npm install @pinia/nuxt pinia @vueuse/nuxt @vueuse/core
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Development Workflow
|
||||
```bash
|
||||
# Start Django backend
|
||||
cd backend && uv run manage.py runserver
|
||||
|
||||
# Start Nuxt frontend (once created)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# Test API connectivity
|
||||
curl http://localhost:8000/api/v1/health/
|
||||
```
|
||||
|
||||
## 📋 Documentation Status
|
||||
|
||||
### ✅ Complete
|
||||
- [x] Project status tracking system
|
||||
- [x] Complete project context for LLMs
|
||||
- [x] Continuation instructions and troubleshooting
|
||||
- [x] Comprehensive requirements (10 core features)
|
||||
- [x] Technical architecture and decisions
|
||||
- [x] Implementation strategy and timeline
|
||||
- [x] LLM handoff prompts (start and continue)
|
||||
|
||||
### 📁 Ready for Creation
|
||||
- [ ] Component library analysis (once chosen)
|
||||
- [ ] Phase implementation guides
|
||||
- [ ] API integration reference
|
||||
- [ ] Code templates and boilerplates
|
||||
- [ ] Testing strategy implementation
|
||||
|
||||
## 🎉 What's Been Accomplished
|
||||
|
||||
### Complete Planning Phase
|
||||
1. **Requirements Analysis** - All functional and technical requirements documented
|
||||
2. **Architecture Design** - Complete system architecture with technology decisions
|
||||
3. **Implementation Strategy** - 4-phase implementation plan with detailed timelines
|
||||
4. **Documentation Structure** - LLM-optimized documentation for seamless handoffs
|
||||
5. **Development Workflow** - Clear processes for development, testing, and deployment
|
||||
|
||||
### Ready for Implementation
|
||||
- All architectural decisions made
|
||||
- Development environment requirements specified
|
||||
- Implementation phases clearly defined
|
||||
- Success criteria established
|
||||
- Risk mitigation strategies planned
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Choose Component Library** - Critical blocking decision
|
||||
2. **Use Start Prompt** - Copy from `PROMPT-START-IMPLEMENTATION.md`
|
||||
3. **Begin Phase 1** - Set up frontend/ directory and authentication
|
||||
4. **Follow Documentation** - Use this comprehensive plan as guide
|
||||
|
||||
**The ThrillWiki Nuxt frontend is fully planned and ready for implementation!** 🚀
|
||||
|
||||
---
|
||||
|
||||
*This documentation structure is optimized for LLM handoffs and ensures seamless continuation of work across multiple sessions.*
|
||||
600
docs/nuxt/planning/architecture-decisions.md
Normal file
@@ -0,0 +1,600 @@
|
||||
# 🏗️ ThrillWiki Nuxt Frontend - Architecture Decisions
|
||||
|
||||
**Status:** ✅ COMPLETE
|
||||
**Last Updated:** 2025-01-27 19:58 UTC
|
||||
**Dependencies:** requirements.md
|
||||
**Blocks:** All implementation phases
|
||||
|
||||
## 🎯 Architecture Overview
|
||||
|
||||
### System Architecture
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ThrillWiki System │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Frontend (Nuxt 3) │ Backend (Django) │
|
||||
│ ┌─────────────────────┐ │ ┌─────────────────────┐ │
|
||||
│ │ Pages & Components │ │ │ REST API (/api/v1/) │ │
|
||||
│ │ ├─ Parks │ │ │ ├─ Authentication │ │
|
||||
│ │ ├─ Rides │ │ │ ├─ Parks CRUD │ │
|
||||
│ │ ├─ Auth │ │ │ ├─ Rides CRUD │ │
|
||||
│ │ └─ Admin │ │ │ ├─ Photos │ │
|
||||
│ └─────────────────────┘ │ │ └─ Moderation │ │
|
||||
│ ┌─────────────────────┐ │ └─────────────────────┘ │
|
||||
│ │ Composables │ │ ┌─────────────────────┐ │
|
||||
│ │ ├─ useAuth │◄───┼──┤ JWT Authentication │ │
|
||||
│ │ ├─ useApi │◄───┼──┤ Token Management │ │
|
||||
│ │ ├─ useParks │◄───┼──┤ CORS Configuration │ │
|
||||
│ │ └─ useRides │ │ └─────────────────────┘ │
|
||||
│ └─────────────────────┘ │ │
|
||||
│ ┌─────────────────────┐ │ ┌─────────────────────┐ │
|
||||
│ │ Component Library │ │ │ Database (PostgreSQL)│ │
|
||||
│ │ ├─ UI Components │ │ │ ├─ Parks │ │
|
||||
│ │ ├─ Forms │ │ │ ├─ Rides │ │
|
||||
│ │ ├─ Navigation │ │ │ ├─ Users │ │
|
||||
│ │ └─ Modals │ │ │ └─ Photos │ │
|
||||
│ └─────────────────────┘ │ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Technology Stack Decisions
|
||||
|
||||
#### Frontend Framework: Nuxt 3
|
||||
**Decision:** Use Nuxt 3 with Vue 3 Composition API
|
||||
**Rationale:**
|
||||
- **Server-Side Rendering:** Better SEO and initial load performance
|
||||
- **File-based Routing:** Intuitive page organization
|
||||
- **Auto-imports:** Reduced boilerplate code
|
||||
- **Built-in Optimization:** Image optimization, code splitting, etc.
|
||||
- **TypeScript Support:** First-class TypeScript integration
|
||||
- **Ecosystem:** Rich ecosystem with modules and plugins
|
||||
|
||||
**Alternatives Considered:**
|
||||
- **Next.js:** Rejected due to React requirement
|
||||
- **SvelteKit:** Rejected due to smaller ecosystem
|
||||
- **Vite + Vue:** Rejected due to lack of SSR out-of-the-box
|
||||
|
||||
#### State Management: Pinia
|
||||
**Decision:** Use Pinia for global state management
|
||||
**Rationale:**
|
||||
- **Vue 3 Native:** Built specifically for Vue 3
|
||||
- **TypeScript Support:** Excellent TypeScript integration
|
||||
- **DevTools:** Great debugging experience
|
||||
- **Modular:** Easy to organize stores by feature
|
||||
- **Performance:** Optimized for Vue 3 reactivity
|
||||
|
||||
**Alternatives Considered:**
|
||||
- **Vuex:** Rejected due to Vue 3 compatibility issues
|
||||
- **Composables Only:** Rejected for complex state management needs
|
||||
|
||||
#### Component Library: TBD (User Choice Required)
|
||||
**Status:** ⏳ PENDING USER DECISION
|
||||
**Options Analyzed:**
|
||||
|
||||
##### Option 1: Nuxt UI (Recommended)
|
||||
```typescript
|
||||
// Installation
|
||||
npm install @nuxt/ui
|
||||
|
||||
// Configuration
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@nuxt/ui'],
|
||||
ui: {
|
||||
global: true,
|
||||
icons: ['heroicons']
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Built specifically for Nuxt 3
|
||||
- Tailwind CSS integration
|
||||
- Headless UI foundation (accessibility)
|
||||
- TypeScript support
|
||||
- Modern design system
|
||||
- Tree-shakable
|
||||
|
||||
**Cons:**
|
||||
- Newer library (less mature)
|
||||
- Smaller component set
|
||||
- Limited complex components
|
||||
|
||||
##### Option 2: Vuetify
|
||||
```typescript
|
||||
// Installation
|
||||
npm install vuetify @mdi/font
|
||||
|
||||
// Configuration
|
||||
import { createVuetify } from 'vuetify'
|
||||
export default defineNuxtPlugin(() => {
|
||||
const vuetify = createVuetify({
|
||||
theme: { defaultTheme: 'light' }
|
||||
})
|
||||
return { provide: { vuetify } }
|
||||
})
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Mature, battle-tested
|
||||
- Comprehensive component set
|
||||
- Material Design system
|
||||
- Strong community
|
||||
- Good documentation
|
||||
|
||||
**Cons:**
|
||||
- Large bundle size
|
||||
- Material Design constraints
|
||||
- Vue 3 support still evolving
|
||||
- Less customizable
|
||||
|
||||
##### Option 3: PrimeVue
|
||||
```typescript
|
||||
// Installation
|
||||
npm install primevue primeicons
|
||||
|
||||
// Configuration
|
||||
import PrimeVue from 'primevue/config'
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(PrimeVue)
|
||||
})
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Enterprise-focused
|
||||
- Comprehensive components
|
||||
- Good TypeScript support
|
||||
- Professional themes
|
||||
- Accessibility features
|
||||
|
||||
**Cons:**
|
||||
- Commercial themes cost
|
||||
- Learning curve
|
||||
- Less modern design
|
||||
- Larger bundle size
|
||||
|
||||
#### Authentication: JWT with Refresh Tokens
|
||||
**Decision:** Implement JWT authentication with refresh token mechanism
|
||||
**Rationale:**
|
||||
- **Stateless:** No server-side session storage required
|
||||
- **Scalable:** Works well with multiple frontend instances
|
||||
- **Secure:** Short-lived access tokens with refresh mechanism
|
||||
- **Standard:** Industry standard for API authentication
|
||||
|
||||
**Implementation Strategy:**
|
||||
```typescript
|
||||
// composables/useAuth.ts
|
||||
export const useAuth = () => {
|
||||
const accessToken = useCookie('access_token', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 15 * 60 // 15 minutes
|
||||
})
|
||||
|
||||
const refreshToken = useCookie('refresh_token', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 // 7 days
|
||||
})
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
// Auto-refresh logic
|
||||
}
|
||||
|
||||
return {
|
||||
login, logout, refreshAccessToken,
|
||||
isAuthenticated: computed(() => !!accessToken.value)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### API Integration: Custom Composables with $fetch
|
||||
**Decision:** Use Nuxt's built-in $fetch with custom composables
|
||||
**Rationale:**
|
||||
- **Built-in:** No additional HTTP client needed
|
||||
- **SSR Compatible:** Works seamlessly with server-side rendering
|
||||
- **Type Safe:** Full TypeScript support
|
||||
- **Caching:** Built-in request caching
|
||||
- **Error Handling:** Consistent error handling patterns
|
||||
|
||||
**Implementation Pattern:**
|
||||
```typescript
|
||||
// composables/useApi.ts
|
||||
export const useApi = () => {
|
||||
const { $fetch } = useNuxtApp()
|
||||
const { accessToken } = useAuth()
|
||||
|
||||
const apiCall = async (endpoint: string, options: any = {}) => {
|
||||
return await $fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken.value}`,
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { apiCall }
|
||||
}
|
||||
```
|
||||
|
||||
### Project Structure Decisions
|
||||
|
||||
#### Directory Structure
|
||||
```
|
||||
frontend/
|
||||
├── assets/ # Static assets (images, fonts, etc.)
|
||||
├── components/ # Vue components
|
||||
│ ├── ui/ # UI library components
|
||||
│ ├── layout/ # Layout components
|
||||
│ ├── forms/ # Form components
|
||||
│ └── features/ # Feature-specific components
|
||||
│ ├── parks/ # Park-related components
|
||||
│ ├── rides/ # Ride-related components
|
||||
│ ├── auth/ # Authentication components
|
||||
│ └── admin/ # Admin/moderation components
|
||||
├── composables/ # Vue composables
|
||||
│ ├── useAuth.ts # Authentication logic
|
||||
│ ├── useApi.ts # API integration
|
||||
│ ├── useParks.ts # Parks data management
|
||||
│ ├── useRides.ts # Rides data management
|
||||
│ └── useModeration.ts # Moderation logic
|
||||
├── layouts/ # Nuxt layouts
|
||||
│ ├── default.vue # Default layout
|
||||
│ ├── auth.vue # Authentication layout
|
||||
│ └── admin.vue # Admin layout
|
||||
├── middleware/ # Route middleware
|
||||
│ ├── auth.ts # Authentication middleware
|
||||
│ └── admin.ts # Admin access middleware
|
||||
├── pages/ # File-based routing
|
||||
│ ├── index.vue # Homepage
|
||||
│ ├── parks/ # Parks pages
|
||||
│ ├── rides/ # Rides pages
|
||||
│ ├── auth/ # Authentication pages
|
||||
│ └── admin/ # Admin pages
|
||||
├── plugins/ # Nuxt plugins
|
||||
│ ├── api.client.ts # API configuration
|
||||
│ └── context7.client.ts # Context7 integration
|
||||
├── stores/ # Pinia stores
|
||||
│ ├── auth.ts # Authentication store
|
||||
│ ├── parks.ts # Parks store
|
||||
│ └── ui.ts # UI state store
|
||||
├── types/ # TypeScript type definitions
|
||||
│ ├── api.ts # API response types
|
||||
│ ├── auth.ts # Authentication types
|
||||
│ └── components.ts # Component prop types
|
||||
├── utils/ # Utility functions
|
||||
│ ├── validation.ts # Form validation
|
||||
│ ├── formatting.ts # Data formatting
|
||||
│ └── constants.ts # Application constants
|
||||
├── nuxt.config.ts # Nuxt configuration
|
||||
├── package.json # Dependencies
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
||||
### Development Environment Decisions
|
||||
|
||||
#### Package Manager: npm
|
||||
**Decision:** Use npm for package management
|
||||
**Rationale:**
|
||||
- **Consistency:** Matches existing project setup
|
||||
- **Reliability:** Stable and well-supported
|
||||
- **Lock File:** package-lock.json for reproducible builds
|
||||
- **CI/CD:** Easy integration with deployment pipelines
|
||||
|
||||
#### Development Server Configuration
|
||||
```typescript
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: true },
|
||||
|
||||
// Development server configuration
|
||||
devServer: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
|
||||
// Proxy API calls to Django backend
|
||||
nitro: {
|
||||
devProxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Runtime configuration
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:8000/api/v1'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### Environment Variables
|
||||
```bash
|
||||
# .env
|
||||
NUXT_PUBLIC_API_BASE=http://localhost:8000/api/v1
|
||||
NUXT_SECRET_JWT_SECRET=your-jwt-secret
|
||||
NUXT_PUBLIC_APP_NAME=ThrillWiki
|
||||
NUXT_PUBLIC_APP_VERSION=1.0.0
|
||||
```
|
||||
|
||||
### Performance Optimization Decisions
|
||||
|
||||
#### Code Splitting Strategy
|
||||
**Decision:** Implement route-based and component-based code splitting
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// Lazy load heavy components
|
||||
const PhotoGallery = defineAsyncComponent(() => import('~/components/PhotoGallery.vue'))
|
||||
|
||||
// Route-based splitting (automatic with Nuxt)
|
||||
// pages/admin/ - Admin bundle
|
||||
// pages/parks/ - Parks bundle
|
||||
// pages/rides/ - Rides bundle
|
||||
```
|
||||
|
||||
#### Image Optimization
|
||||
**Decision:** Use Nuxt Image module for automatic optimization
|
||||
**Configuration:**
|
||||
```typescript
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@nuxt/image'],
|
||||
image: {
|
||||
provider: 'ipx',
|
||||
quality: 80,
|
||||
format: ['webp', 'avif', 'jpg'],
|
||||
screens: {
|
||||
xs: 320,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### Caching Strategy
|
||||
**Decision:** Multi-layer caching approach
|
||||
**Layers:**
|
||||
1. **Browser Cache:** Static assets with long cache times
|
||||
2. **API Cache:** Response caching with TTL
|
||||
3. **Component Cache:** Expensive component computations
|
||||
4. **Route Cache:** Static route pre-generation
|
||||
|
||||
```typescript
|
||||
// API caching example
|
||||
export const useParks = () => {
|
||||
const { data: parks } = useLazyFetch('/api/v1/parks/', {
|
||||
key: 'parks-list',
|
||||
server: true,
|
||||
default: () => [],
|
||||
transform: (data: any) => data.results || data
|
||||
})
|
||||
|
||||
return { parks }
|
||||
}
|
||||
```
|
||||
|
||||
### Security Decisions
|
||||
|
||||
#### Token Storage
|
||||
**Decision:** Use HTTP-only cookies for token storage
|
||||
**Rationale:**
|
||||
- **XSS Protection:** Tokens not accessible via JavaScript
|
||||
- **CSRF Protection:** SameSite cookie attribute
|
||||
- **Automatic Handling:** Browser handles cookie management
|
||||
|
||||
#### Input Validation
|
||||
**Decision:** Client-side validation with server-side verification
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// utils/validation.ts
|
||||
import { z } from 'zod'
|
||||
|
||||
export const parkSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
location: z.string().min(1),
|
||||
operator: z.string().optional(),
|
||||
status: z.enum(['OPERATING', 'CLOSED', 'UNDER_CONSTRUCTION'])
|
||||
})
|
||||
|
||||
export type ParkInput = z.infer<typeof parkSchema>
|
||||
```
|
||||
|
||||
#### CORS Configuration
|
||||
**Decision:** Strict CORS policy for production
|
||||
**Django Configuration:**
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"https://thrillwiki.com",
|
||||
"https://www.thrillwiki.com"
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_ALL_ORIGINS = False # Never true in production
|
||||
```
|
||||
|
||||
### Testing Strategy Decisions
|
||||
|
||||
#### Testing Framework: Vitest
|
||||
**Decision:** Use Vitest for unit and component testing
|
||||
**Rationale:**
|
||||
- **Vite Integration:** Fast test execution
|
||||
- **Vue Support:** Excellent Vue component testing
|
||||
- **TypeScript:** Native TypeScript support
|
||||
- **Jest Compatible:** Familiar API for developers
|
||||
|
||||
#### E2E Testing: Playwright
|
||||
**Decision:** Use Playwright for end-to-end testing
|
||||
**Rationale:**
|
||||
- **Cross-browser:** Chrome, Firefox, Safari support
|
||||
- **Mobile Testing:** Mobile browser simulation
|
||||
- **Reliable:** Stable test execution
|
||||
- **Modern:** Built for modern web applications
|
||||
|
||||
#### Testing Configuration
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
threshold: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Deployment Decisions
|
||||
|
||||
#### Build Strategy
|
||||
**Decision:** Hybrid rendering with static generation for public pages
|
||||
**Configuration:**
|
||||
```typescript
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
nitro: {
|
||||
prerender: {
|
||||
routes: [
|
||||
'/',
|
||||
'/parks',
|
||||
'/rides',
|
||||
'/about',
|
||||
'/privacy',
|
||||
'/terms'
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Route rules for different rendering strategies
|
||||
routeRules: {
|
||||
'/': { prerender: true },
|
||||
'/parks': { prerender: true },
|
||||
'/rides': { prerender: true },
|
||||
'/admin/**': { ssr: false }, // SPA mode for admin
|
||||
'/auth/**': { ssr: false } // SPA mode for auth
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### Docker Configuration
|
||||
**Decision:** Multi-stage Docker build for production
|
||||
**Dockerfile:**
|
||||
```dockerfile
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS production
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.output ./
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server/index.mjs"]
|
||||
```
|
||||
|
||||
### Context7 Integration Decisions
|
||||
|
||||
#### Documentation Strategy
|
||||
**Decision:** Integrate Context7 for automatic documentation generation
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// plugins/context7.client.ts
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (process.dev) {
|
||||
// Initialize Context7 connection
|
||||
const context7 = new Context7Client({
|
||||
endpoint: 'http://localhost:8080',
|
||||
project: 'thrillwiki-frontend'
|
||||
})
|
||||
|
||||
// Auto-document API calls
|
||||
context7.trackApiCalls()
|
||||
|
||||
// Document component usage
|
||||
context7.trackComponentUsage()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### Knowledge Preservation
|
||||
**Decision:** Structured documentation with progress tracking
|
||||
**Features:**
|
||||
- Automatic API endpoint documentation
|
||||
- Component usage tracking
|
||||
- Implementation decision logging
|
||||
- Progress milestone tracking
|
||||
- LLM handoff preparation
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Implementation Priorities
|
||||
|
||||
### Phase 1: Foundation (Week 1)
|
||||
1. **Project Setup:** Initialize Nuxt 3 with TypeScript
|
||||
2. **Component Library:** Integrate chosen UI library
|
||||
3. **Authentication:** Implement JWT auth system
|
||||
4. **API Integration:** Set up Django backend communication
|
||||
5. **Basic Layout:** Create header, footer, navigation
|
||||
|
||||
### Phase 2: Core Features (Week 2)
|
||||
1. **Parks System:** Listing, detail, search functionality
|
||||
2. **Rides System:** Listing, detail, filtering
|
||||
3. **User Profiles:** Profile management and settings
|
||||
4. **Photo System:** Upload, display, basic moderation
|
||||
|
||||
### Phase 3: Advanced Features (Week 3)
|
||||
1. **Submission System:** Content submission workflow
|
||||
2. **Moderation Interface:** Admin tools and queues
|
||||
3. **Advanced Search:** Filters, autocomplete, suggestions
|
||||
4. **Maps Integration:** Location visualization
|
||||
|
||||
### Phase 4: Polish & Deployment (Week 4)
|
||||
1. **Performance Optimization:** Bundle size, loading times
|
||||
2. **Testing:** Comprehensive test suite
|
||||
3. **Documentation:** Complete user and developer docs
|
||||
4. **Deployment:** Production setup and monitoring
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Critical Dependencies
|
||||
|
||||
### Immediate Blockers
|
||||
1. **Component Library Choice:** Must be decided before implementation
|
||||
2. **Django JWT Setup:** Backend enhancement required
|
||||
3. **Development Environment:** CORS and proxy configuration
|
||||
|
||||
### Technical Dependencies
|
||||
1. **Node.js 18+:** Required for Nuxt 3
|
||||
2. **Django Backend:** Must be running for development
|
||||
3. **PostgreSQL:** Database must be accessible
|
||||
4. **Context7:** Integration approach needs clarification
|
||||
|
||||
---
|
||||
|
||||
**Next Document:** `component-library-analysis.md` - Detailed analysis of chosen library
|
||||
**Status:** Ready for component library decision and implementation start
|
||||
445
docs/nuxt/planning/requirements.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# 📋 ThrillWiki Nuxt Frontend - Detailed Requirements
|
||||
|
||||
**Status:** ✅ COMPLETE
|
||||
**Last Updated:** 2025-01-27 19:55 UTC
|
||||
**Dependencies:** None
|
||||
**Blocks:** All implementation phases
|
||||
|
||||
## 🎯 Project Overview
|
||||
|
||||
### Primary Goal
|
||||
Create a modern, responsive Nuxt 3 frontend for ThrillWiki that seamlessly integrates with the existing Django REST API backend, providing users with an intuitive interface for discovering, submitting, and moderating theme park and ride content.
|
||||
|
||||
### Success Criteria
|
||||
1. **Functionality Parity:** All Django backend features accessible through frontend
|
||||
2. **Performance:** Sub-3s initial load, sub-1s navigation between pages
|
||||
3. **User Experience:** Intuitive, mobile-first design with smooth interactions
|
||||
4. **Maintainability:** Clean, documented, testable codebase
|
||||
5. **Scalability:** Architecture supports future feature additions
|
||||
|
||||
## 🏗️ Technical Requirements
|
||||
|
||||
### Framework & Architecture
|
||||
- **Frontend Framework:** Nuxt 3 with Vue 3 Composition API
|
||||
- **Language:** TypeScript for type safety and better developer experience
|
||||
- **State Management:** Pinia for global state management
|
||||
- **Component Library:** TBD - Existing reusable component library (user choice)
|
||||
- **Styling:** Tailwind CSS (or library's preferred styling system)
|
||||
- **Build Tool:** Vite (included with Nuxt 3)
|
||||
|
||||
### Authentication & Security
|
||||
- **Authentication Method:** JWT with refresh tokens
|
||||
- **Token Storage:** HTTP-only cookies for security
|
||||
- **Session Management:** Automatic token refresh
|
||||
- **Route Protection:** Middleware for protected routes
|
||||
- **CSRF Protection:** Integration with Django CSRF tokens
|
||||
- **Input Validation:** Client-side validation with server-side verification
|
||||
|
||||
### API Integration
|
||||
- **Backend API:** Django REST API at `/api/v1/`
|
||||
- **HTTP Client:** Nuxt's built-in $fetch with custom composables
|
||||
- **Error Handling:** Comprehensive error handling and user feedback
|
||||
- **Caching:** Smart caching strategy for API responses
|
||||
- **Real-time Updates:** WebSocket integration for live updates (future)
|
||||
|
||||
### Performance Requirements
|
||||
- **Initial Load:** < 3 seconds on 3G connection
|
||||
- **Navigation:** < 1 second between pages
|
||||
- **Bundle Size:** < 500KB initial JavaScript bundle
|
||||
- **Image Optimization:** Lazy loading and responsive images
|
||||
- **SEO:** Server-side rendering for public pages
|
||||
|
||||
## 🎨 User Interface Requirements
|
||||
|
||||
### Design System
|
||||
- **Design Philosophy:** Modern, clean, user-centric interface
|
||||
- **Responsive Design:** Mobile-first approach with breakpoints:
|
||||
- Mobile: 320px - 767px
|
||||
- Tablet: 768px - 1023px
|
||||
- Desktop: 1024px+
|
||||
- **Accessibility:** WCAG 2.1 AA compliance
|
||||
- **Theme Support:** Light/dark mode with system preference detection
|
||||
- **Typography:** Clear hierarchy with readable fonts
|
||||
- **Color Palette:** Modern, accessible color scheme
|
||||
|
||||
### Component Requirements
|
||||
- **Reusable Components:** Consistent design system components
|
||||
- **Form Components:** Validation, error handling, accessibility
|
||||
- **Navigation Components:** Header, sidebar, breadcrumbs, pagination
|
||||
- **Data Display:** Cards, tables, lists, galleries
|
||||
- **Interactive Components:** Modals, dropdowns, tooltips, tabs
|
||||
- **Feedback Components:** Alerts, notifications, loading states
|
||||
|
||||
## 🚀 Functional Requirements
|
||||
|
||||
### Core Features
|
||||
|
||||
#### 1. Authentication System
|
||||
**Priority:** High
|
||||
**Description:** Complete user authentication and account management
|
||||
|
||||
**Features:**
|
||||
- User registration with email verification
|
||||
- Login/logout with "remember me" option
|
||||
- Password reset via email
|
||||
- Profile management (avatar, bio, preferences)
|
||||
- Account settings and privacy controls
|
||||
- Social authentication (future enhancement)
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Users can register with email and password
|
||||
- [ ] Email verification required for new accounts
|
||||
- [ ] Secure login/logout with JWT tokens
|
||||
- [ ] Password reset functionality works
|
||||
- [ ] Profile information can be updated
|
||||
- [ ] Account deletion option available
|
||||
|
||||
#### 2. Parks Management
|
||||
**Priority:** High
|
||||
**Description:** Browse, search, and manage theme park information
|
||||
|
||||
**Features:**
|
||||
- Parks listing with search and filtering
|
||||
- Detailed park pages with photos and information
|
||||
- Park submission form for new parks
|
||||
- Photo upload and gallery management
|
||||
- Reviews and ratings system
|
||||
- Location-based search with maps
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Parks can be browsed with pagination
|
||||
- [ ] Search works by name, location, operator
|
||||
- [ ] Filtering by status, country, operator
|
||||
- [ ] Park detail pages show complete information
|
||||
- [ ] Users can submit new parks for approval
|
||||
- [ ] Photo upload works with moderation queue
|
||||
- [ ] Map integration shows park locations
|
||||
|
||||
#### 3. Rides Management
|
||||
**Priority:** High
|
||||
**Description:** Browse, search, and manage ride information
|
||||
|
||||
**Features:**
|
||||
- Rides listing with advanced filtering
|
||||
- Detailed ride pages with specifications
|
||||
- Ride submission form with validation
|
||||
- Photo galleries and media management
|
||||
- Manufacturer and model information
|
||||
- Ride rankings and statistics
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Rides can be browsed and filtered by category
|
||||
- [ ] Search works by name, park, manufacturer
|
||||
- [ ] Ride specifications displayed clearly
|
||||
- [ ] Users can submit new rides
|
||||
- [ ] Photo management with approval workflow
|
||||
- [ ] Rankings and statistics visible
|
||||
|
||||
#### 4. Content Submission System
|
||||
**Priority:** High
|
||||
**Description:** User-generated content with moderation workflow
|
||||
|
||||
**Features:**
|
||||
- Submission forms for parks, rides, photos
|
||||
- Draft saving and auto-save functionality
|
||||
- Submission status tracking
|
||||
- User submission history
|
||||
- Collaborative editing (future)
|
||||
- Bulk submission tools (admin)
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Users can submit parks and rides
|
||||
- [ ] Forms validate input and show errors
|
||||
- [ ] Drafts are saved automatically
|
||||
- [ ] Users can track submission status
|
||||
- [ ] Submission history is accessible
|
||||
- [ ] Admins can bulk approve/reject
|
||||
|
||||
#### 5. Moderation Interface
|
||||
**Priority:** High
|
||||
**Description:** Admin tools for content approval and management
|
||||
|
||||
**Features:**
|
||||
- Moderation queue with filtering
|
||||
- Bulk approval/rejection actions
|
||||
- Moderation notes and feedback
|
||||
- User reputation system
|
||||
- Content flagging and reporting
|
||||
- Moderation analytics dashboard
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Moderators can view pending submissions
|
||||
- [ ] Bulk actions work for multiple items
|
||||
- [ ] Moderation notes can be added
|
||||
- [ ] User reputation affects submission priority
|
||||
- [ ] Flagged content appears in queue
|
||||
- [ ] Analytics show moderation metrics
|
||||
|
||||
#### 6. Search & Discovery
|
||||
**Priority:** Medium
|
||||
**Description:** Advanced search and content discovery features
|
||||
|
||||
**Features:**
|
||||
- Global search across parks and rides
|
||||
- Autocomplete suggestions
|
||||
- Advanced filtering options
|
||||
- Trending content section
|
||||
- Recently added content
|
||||
- Personalized recommendations (future)
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Global search returns relevant results
|
||||
- [ ] Autocomplete works as user types
|
||||
- [ ] Filters can be combined effectively
|
||||
- [ ] Trending content updates regularly
|
||||
- [ ] New content is highlighted
|
||||
- [ ] Search performance is fast
|
||||
|
||||
#### 7. User Profiles & Social Features
|
||||
**Priority:** Medium
|
||||
**Description:** User profiles and social interaction features
|
||||
|
||||
**Features:**
|
||||
- Public user profiles
|
||||
- Top lists and favorites
|
||||
- User statistics and achievements
|
||||
- Following/followers system (future)
|
||||
- Activity feeds (future)
|
||||
- User-generated content showcase
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] User profiles are publicly viewable
|
||||
- [ ] Users can create and share top lists
|
||||
- [ ] Statistics show user activity
|
||||
- [ ] Achievements unlock based on activity
|
||||
- [ ] User content is showcased on profile
|
||||
- [ ] Privacy settings control visibility
|
||||
|
||||
### Advanced Features
|
||||
|
||||
#### 8. Photo Management
|
||||
**Priority:** Medium
|
||||
**Description:** Comprehensive photo upload and management system
|
||||
|
||||
**Features:**
|
||||
- Drag-and-drop photo upload
|
||||
- Image cropping and editing tools
|
||||
- Photo galleries with lightbox
|
||||
- Bulk photo operations
|
||||
- Photo metadata and tagging
|
||||
- Image optimization and CDN
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Photos can be uploaded via drag-and-drop
|
||||
- [ ] Basic editing tools available
|
||||
- [ ] Galleries display photos attractively
|
||||
- [ ] Bulk operations work efficiently
|
||||
- [ ] Photos are optimized automatically
|
||||
- [ ] CDN integration improves performance
|
||||
|
||||
#### 9. Maps Integration
|
||||
**Priority:** Medium
|
||||
**Description:** Interactive maps for park and ride locations
|
||||
|
||||
**Features:**
|
||||
- Interactive park location maps
|
||||
- Clustering for dense areas
|
||||
- Custom markers and popups
|
||||
- Directions integration
|
||||
- Mobile-friendly map controls
|
||||
- Offline map support (future)
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Maps show accurate park locations
|
||||
- [ ] Clustering works for nearby parks
|
||||
- [ ] Markers show park information
|
||||
- [ ] Directions can be requested
|
||||
- [ ] Maps work well on mobile
|
||||
- [ ] Performance is acceptable
|
||||
|
||||
#### 10. Rankings & Statistics
|
||||
**Priority:** Low
|
||||
**Description:** Display and interact with ride ranking system
|
||||
|
||||
**Features:**
|
||||
- Ride rankings display
|
||||
- Ranking history and trends
|
||||
- User voting interface (future)
|
||||
- Statistical analysis
|
||||
- Comparison tools
|
||||
- Export functionality
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Rankings display correctly
|
||||
- [ ] Historical data is accessible
|
||||
- [ ] Statistics are accurate
|
||||
- [ ] Comparisons are meaningful
|
||||
- [ ] Data can be exported
|
||||
- [ ] Performance is good with large datasets
|
||||
|
||||
## 🔧 Technical Specifications
|
||||
|
||||
### Component Library Options
|
||||
**Status:** ⏳ PENDING USER CHOICE
|
||||
|
||||
#### Option 1: Nuxt UI (Recommended)
|
||||
**Pros:**
|
||||
- Built specifically for Nuxt 3
|
||||
- Tailwind CSS integration
|
||||
- TypeScript support
|
||||
- Modern design system
|
||||
- Active development
|
||||
|
||||
**Cons:**
|
||||
- Newer library, smaller ecosystem
|
||||
- Limited complex components
|
||||
|
||||
#### Option 2: Vuetify
|
||||
**Pros:**
|
||||
- Mature, comprehensive library
|
||||
- Material Design system
|
||||
- Extensive component set
|
||||
- Strong community support
|
||||
|
||||
**Cons:**
|
||||
- Larger bundle size
|
||||
- Material Design may not fit brand
|
||||
- Vue 3 support still maturing
|
||||
|
||||
#### Option 3: PrimeVue
|
||||
**Pros:**
|
||||
- Enterprise-focused
|
||||
- Comprehensive component set
|
||||
- Good TypeScript support
|
||||
- Professional themes
|
||||
|
||||
**Cons:**
|
||||
- Commercial themes cost money
|
||||
- Larger learning curve
|
||||
- Less modern design
|
||||
|
||||
### Development Environment
|
||||
- **Node.js:** Version 18+ required
|
||||
- **Package Manager:** npm (consistent with project)
|
||||
- **Development Server:** Nuxt dev server with HMR
|
||||
- **Proxy Configuration:** API calls proxied to Django backend
|
||||
- **Environment Variables:** Separate configs for dev/staging/production
|
||||
|
||||
### Build & Deployment
|
||||
- **Build Tool:** Nuxt build with Vite
|
||||
- **Output:** Static generation for public pages, SSR for dynamic content
|
||||
- **Docker:** Multi-stage build for production
|
||||
- **CI/CD:** GitHub Actions for automated testing and deployment
|
||||
- **Monitoring:** Error tracking and performance monitoring
|
||||
|
||||
## 📊 Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
- **Page Load Time:** < 3 seconds initial load
|
||||
- **Navigation:** < 1 second between pages
|
||||
- **API Response Time:** < 500ms for most endpoints
|
||||
- **Bundle Size:** < 500KB initial JavaScript
|
||||
- **Image Loading:** Progressive loading with placeholders
|
||||
|
||||
### Scalability
|
||||
- **Concurrent Users:** Support 1000+ concurrent users
|
||||
- **Data Volume:** Handle 10,000+ parks and 50,000+ rides
|
||||
- **API Calls:** Efficient caching to minimize backend load
|
||||
- **Database:** Optimized queries and indexing
|
||||
|
||||
### Security
|
||||
- **Authentication:** Secure JWT implementation
|
||||
- **Data Validation:** Client and server-side validation
|
||||
- **XSS Protection:** Sanitized user input
|
||||
- **CSRF Protection:** Token-based protection
|
||||
- **HTTPS:** All production traffic encrypted
|
||||
|
||||
### Accessibility
|
||||
- **WCAG Compliance:** Level AA compliance
|
||||
- **Keyboard Navigation:** Full keyboard accessibility
|
||||
- **Screen Readers:** Proper ARIA labels and roles
|
||||
- **Color Contrast:** Minimum 4.5:1 contrast ratio
|
||||
- **Focus Management:** Clear focus indicators
|
||||
|
||||
### Browser Support
|
||||
- **Modern Browsers:** Chrome, Firefox, Safari, Edge (latest 2 versions)
|
||||
- **Mobile Browsers:** iOS Safari, Chrome Mobile
|
||||
- **Progressive Enhancement:** Basic functionality without JavaScript
|
||||
- **Polyfills:** Minimal polyfills for essential features
|
||||
|
||||
## 🧪 Testing Requirements
|
||||
|
||||
### Testing Strategy
|
||||
- **Unit Tests:** 80%+ code coverage for utilities and composables
|
||||
- **Component Tests:** All UI components tested
|
||||
- **Integration Tests:** API integration and user flows
|
||||
- **E2E Tests:** Critical user journeys automated
|
||||
- **Performance Tests:** Load testing and optimization
|
||||
|
||||
### Testing Tools
|
||||
- **Unit Testing:** Vitest for fast unit tests
|
||||
- **Component Testing:** Vue Test Utils with Vitest
|
||||
- **E2E Testing:** Playwright for cross-browser testing
|
||||
- **Visual Testing:** Chromatic for visual regression
|
||||
- **Performance Testing:** Lighthouse CI for performance monitoring
|
||||
|
||||
## 📚 Documentation Requirements
|
||||
|
||||
### Code Documentation
|
||||
- **Component Documentation:** Props, events, slots documented
|
||||
- **API Documentation:** All composables and utilities documented
|
||||
- **Type Definitions:** Comprehensive TypeScript types
|
||||
- **Examples:** Usage examples for all components
|
||||
- **Storybook:** Interactive component documentation
|
||||
|
||||
### User Documentation
|
||||
- **User Guide:** How to use the application
|
||||
- **Admin Guide:** Moderation and administration
|
||||
- **API Guide:** For developers integrating with the system
|
||||
- **Deployment Guide:** Self-hosting instructions
|
||||
- **Troubleshooting:** Common issues and solutions
|
||||
|
||||
### Context7 Integration
|
||||
- **Auto-Documentation:** Automatic API and component docs
|
||||
- **Implementation Tracking:** Progress and decision documentation
|
||||
- **Knowledge Preservation:** Context for future development
|
||||
- **LLM Handoffs:** Structured information for continuation
|
||||
|
||||
---
|
||||
|
||||
## ✅ Acceptance Criteria Summary
|
||||
|
||||
### Phase 1: Foundation (Week 1)
|
||||
- [ ] Nuxt 3 project set up with TypeScript
|
||||
- [ ] Component library integrated and configured
|
||||
- [ ] Authentication system implemented with JWT
|
||||
- [ ] Basic layout and navigation components
|
||||
- [ ] API integration with Django backend
|
||||
- [ ] Development environment fully configured
|
||||
|
||||
### Phase 2: Core Features (Week 2)
|
||||
- [ ] Parks listing and detail pages functional
|
||||
- [ ] Rides listing and detail pages functional
|
||||
- [ ] Search functionality working across content
|
||||
- [ ] Photo upload and display system
|
||||
- [ ] User profile management
|
||||
- [ ] Basic submission forms
|
||||
|
||||
### Phase 3: Advanced Features (Week 3)
|
||||
- [ ] Complete submission system with moderation
|
||||
- [ ] Admin moderation interface
|
||||
- [ ] Advanced search and filtering
|
||||
- [ ] Maps integration for locations
|
||||
- [ ] Rankings and statistics display
|
||||
- [ ] Performance optimization complete
|
||||
|
||||
### Phase 4: Polish & Deployment (Week 4)
|
||||
- [ ] Comprehensive testing suite
|
||||
- [ ] Documentation complete
|
||||
- [ ] Production deployment configured
|
||||
- [ ] Performance monitoring set up
|
||||
- [ ] User acceptance testing passed
|
||||
|
||||
---
|
||||
|
||||
**Next Document:** `architecture-decisions.md` - Technical architecture details
|
||||
1049
plans/frontend-rewrite-plan.md
Normal file
@@ -45,32 +45,37 @@ class Command(BaseCommand):
|
||||
]
|
||||
|
||||
if files:
|
||||
# Get the first file and update the database
|
||||
# record
|
||||
file_path = os.path.join(
|
||||
content_type, identifier, files[0]
|
||||
)
|
||||
# Get the first file and update the database record
|
||||
file_path = os.path.join(content_type, identifier, files[0])
|
||||
if os.path.exists(os.path.join("media", file_path)):
|
||||
photo.image.name = file_path
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"Updated path for photo {
|
||||
photo.id} to {file_path}"
|
||||
f"Updated path for photo {photo.id} to {file_path}"
|
||||
)
|
||||
else:
|
||||
# If the expected file is still missing, fall back to placeholder
|
||||
placeholder = os.path.join("placeholders", "default.svg")
|
||||
photo.image.name = placeholder
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"File not found for photo {
|
||||
photo.id}: {file_path}"
|
||||
f"File missing for photo {photo.id}; set placeholder {placeholder}"
|
||||
)
|
||||
else:
|
||||
# No files found for this identifier -> set placeholder
|
||||
placeholder = os.path.join("placeholders", "default.svg")
|
||||
photo.image.name = placeholder
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"No files found in directory for photo {
|
||||
photo.id}: {media_dir}"
|
||||
f"No files in {media_dir} for photo {photo.id}; set placeholder {placeholder}"
|
||||
)
|
||||
else:
|
||||
# Directory missing -> set placeholder
|
||||
placeholder = os.path.join("placeholders", "default.svg")
|
||||
photo.image.name = placeholder
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"Directory not found for photo {
|
||||
photo.id}: {media_dir}"
|
||||
f"Directory not found for photo {photo.id}: {media_dir}; set placeholder {placeholder}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -49,9 +49,17 @@ class Command(BaseCommand):
|
||||
if files:
|
||||
current_path = os.path.join(old_dir, files[0])
|
||||
|
||||
# Skip if file still not found
|
||||
# If file still not found, set placeholder and continue
|
||||
if not os.path.exists(current_path):
|
||||
self.stdout.write(f"Skipping {current_name} - file not found")
|
||||
placeholder = os.path.join("placeholders", "default.svg")
|
||||
try:
|
||||
photo.image.name = placeholder
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"File for {current_name} not found; set placeholder {placeholder} for photo {photo.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(f"Error setting placeholder for photo {photo.id}: {e}")
|
||||
continue
|
||||
|
||||
# Get content type and object
|
||||
|
||||
|
Before Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 942 KiB |
|
Before Width: | Height: | Size: 3.2 MiB |