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