Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9063ff4f8 | ||
|
|
bf04e4d854 | ||
|
|
1b246eeaa4 | ||
|
|
fdbbca2add | ||
|
|
bf365693f8 |
1
.gitignore
vendored
@@ -122,3 +122,4 @@ frontend/.env
|
||||
django-forwardemail/
|
||||
frontend/
|
||||
frontend
|
||||
.snapshots
|
||||
51
apps/accounts/admin.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import HttpRequest
|
||||
from django.db.models import QuerySet
|
||||
|
||||
# Import models from the backend location
|
||||
from backend.apps.accounts.models import (
|
||||
User,
|
||||
UserProfile,
|
||||
EmailVerification,
|
||||
)
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
list_display = ('username', 'email', 'user_id', 'role', 'is_active', 'is_staff', 'date_joined')
|
||||
list_filter = ('role', 'is_active', 'is_staff', 'is_banned', 'date_joined')
|
||||
search_fields = ('username', 'email', 'user_id', 'display_name')
|
||||
readonly_fields = ('user_id', 'date_joined', 'last_login')
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
('Personal info', {'fields': ('email', 'display_name', 'user_id')}),
|
||||
('Permissions', {'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
('Moderation', {'fields': ('is_banned', 'ban_reason', 'ban_date')}),
|
||||
('Preferences', {'fields': ('theme_preference', 'privacy_level')}),
|
||||
('Notifications', {'fields': ('email_notifications', 'push_notifications')}),
|
||||
)
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'profile_id', 'display_name', 'coaster_credits', 'dark_ride_credits')
|
||||
list_filter = ('user__role', 'user__is_active')
|
||||
search_fields = ('user__username', 'user__email', 'profile_id', 'display_name')
|
||||
readonly_fields = ('profile_id',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('user', 'profile_id', 'display_name')}),
|
||||
('Profile Info', {'fields': ('avatar', 'pronouns', 'bio')}),
|
||||
('Social Media', {'fields': ('twitter', 'instagram', 'youtube', 'discord')}),
|
||||
('Ride Statistics', {'fields': ('coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')}),
|
||||
)
|
||||
|
||||
@admin.register(EmailVerification)
|
||||
class EmailVerificationAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'token', 'created_at', 'last_sent')
|
||||
list_filter = ('created_at', 'last_sent')
|
||||
search_fields = ('user__username', 'user__email', 'token')
|
||||
readonly_fields = ('token', 'created_at', 'last_sent')
|
||||
23
backend/apps/core/forms/htmx_forms.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.views.generic.edit import FormView
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
|
||||
|
||||
class HTMXFormView(FormView):
|
||||
"""Base FormView that supports field-level validation endpoints for HTMX.
|
||||
|
||||
Subclasses can call `validate_field` to return JSON errors for a single field.
|
||||
"""
|
||||
|
||||
def validate_field(self, field_name):
|
||||
"""Return JSON with errors for a single field based on the current form."""
|
||||
form = self.get_form()
|
||||
field = form[field_name]
|
||||
form.is_valid() # populate errors
|
||||
errors = form.errors.get(field_name, [])
|
||||
return JsonResponse({"field": field_name, "errors": errors})
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# If HTMX field validation pattern: ?field=name
|
||||
if request.headers.get("HX-Request") == "true" and request.GET.get("validate_field"):
|
||||
return self.validate_field(request.GET.get("validate_field"))
|
||||
return super().post(request, *args, **kwargs)
|
||||
78
backend/apps/core/htmx_utils.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from functools import wraps
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
def htmx_partial(template_name):
|
||||
"""Decorator for view functions to render partials for HTMX requests.
|
||||
|
||||
If the request is an HTMX request and a partial template exists with
|
||||
the convention '<template_name>_partial.html', that template will be
|
||||
rendered. Otherwise the provided template_name is used.
|
||||
"""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped(request, *args, **kwargs):
|
||||
resp = view_func(request, *args, **kwargs)
|
||||
# If the view returned an HttpResponse, pass through
|
||||
if isinstance(resp, HttpResponse):
|
||||
return resp
|
||||
# Expecting a tuple (context, template_name) or (context,)
|
||||
context = {}
|
||||
tpl = template_name
|
||||
if isinstance(resp, tuple):
|
||||
if len(resp) >= 1:
|
||||
context = resp[0]
|
||||
if len(resp) >= 2 and resp[1]:
|
||||
tpl = resp[1]
|
||||
|
||||
# If HTMX, try partial template
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
partial = tpl.replace(".html", "_partial.html")
|
||||
try:
|
||||
html = render_to_string(partial, context, request=request)
|
||||
return HttpResponse(html)
|
||||
except Exception:
|
||||
# Fall back to full template
|
||||
html = render_to_string(tpl, context, request=request)
|
||||
return HttpResponse(html)
|
||||
|
||||
html = render_to_string(tpl, context, request=request)
|
||||
return HttpResponse(html)
|
||||
|
||||
return _wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def htmx_redirect(url):
|
||||
resp = HttpResponse("")
|
||||
resp["HX-Redirect"] = url
|
||||
return resp
|
||||
|
||||
|
||||
def htmx_trigger(name: str, payload: dict = None):
|
||||
resp = HttpResponse("")
|
||||
if payload is None:
|
||||
resp["HX-Trigger"] = name
|
||||
else:
|
||||
resp["HX-Trigger"] = JsonResponse({name: payload}).content.decode()
|
||||
return resp
|
||||
|
||||
|
||||
def htmx_refresh():
|
||||
resp = HttpResponse("")
|
||||
resp["HX-Refresh"] = "true"
|
||||
return resp
|
||||
|
||||
|
||||
def htmx_swap_oob(target_id: str, html: str):
|
||||
"""Return an out-of-band swap response by wrapping HTML and setting headers.
|
||||
|
||||
Note: For simple use cases this returns an HttpResponse containing the
|
||||
fragment; consumers should set `HX-Boost` headers when necessary.
|
||||
"""
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = f"oob:{target_id}"
|
||||
return resp
|
||||
22
backend/apps/core/middleware/htmx_error_middleware.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import logging
|
||||
from django.http import HttpResponseServerError
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HTMXErrorMiddleware:
|
||||
"""Catch exceptions on HTMX requests and return formatted error partials."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
try:
|
||||
return self.get_response(request)
|
||||
except Exception as exc:
|
||||
logger.exception("Error during request")
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
html = render_to_string("htmx/components/error_message.html", {"title": "Server error", "message": "An unexpected error occurred."})
|
||||
return HttpResponseServerError(html)
|
||||
raise
|
||||
@@ -1,19 +1,86 @@
|
||||
from typing import Optional
|
||||
from django.views.generic.list import MultipleObjectMixin
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django.template.loader import select_template
|
||||
|
||||
|
||||
"""HTMX mixins for views. Single canonical definitions for partial rendering and triggers."""
|
||||
|
||||
|
||||
class HTMXFilterableMixin(MultipleObjectMixin):
|
||||
"""
|
||||
A mixin that provides filtering capabilities for HTMX requests.
|
||||
"""
|
||||
"""Enhance list views to return partial templates for HTMX requests."""
|
||||
|
||||
filter_class = None
|
||||
htmx_partial_suffix = "_partial.html"
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
|
||||
qs = super().get_queryset()
|
||||
if self.filter_class:
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=qs)
|
||||
return self.filterset.qs
|
||||
return qs
|
||||
|
||||
def get_template_names(self):
|
||||
names = super().get_template_names()
|
||||
if self.request.headers.get("HX-Request") == "true":
|
||||
partials = [t.replace(".html", self.htmx_partial_suffix) for t in names]
|
||||
try:
|
||||
select_template(partials)
|
||||
return partials
|
||||
except Exception:
|
||||
return names
|
||||
return names
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["filter"] = self.filterset
|
||||
return context
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
if hasattr(self, "filterset"):
|
||||
ctx["filter"] = self.filterset
|
||||
return ctx
|
||||
|
||||
|
||||
class HTMXFormMixin(FormMixin):
|
||||
"""FormMixin that returns partials and field-level errors for HTMX requests."""
|
||||
|
||||
htmx_success_trigger: Optional[str] = None
|
||||
|
||||
def form_invalid(self, form):
|
||||
if self.request.headers.get("HX-Request") == "true":
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
return super().form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
res = super().form_valid(form)
|
||||
if (
|
||||
self.request.headers.get("HX-Request") == "true"
|
||||
and self.htmx_success_trigger
|
||||
):
|
||||
res["HX-Trigger"] = self.htmx_success_trigger
|
||||
return res
|
||||
|
||||
|
||||
class HTMXInlineEditMixin(FormMixin):
|
||||
"""Support simple inline edit flows: GET returns form partial, POST returns updated fragment."""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class HTMXPaginationMixin:
|
||||
"""Pagination helper that supports hx-trigger based infinite scroll or standard pagination."""
|
||||
|
||||
page_size = 20
|
||||
|
||||
def get_paginate_by(self, queryset):
|
||||
return getattr(self, "paginate_by", self.page_size)
|
||||
|
||||
|
||||
class HTMXModalMixin(HTMXFormMixin):
|
||||
"""Mixin to help render forms inside modals and send close triggers on success."""
|
||||
|
||||
modal_close_trigger = "modal:close"
|
||||
|
||||
def form_valid(self, form):
|
||||
res = super().form_valid(form)
|
||||
if self.request.headers.get("HX-Request") == "true":
|
||||
res["HX-Trigger"] = self.modal_close_trigger
|
||||
return res
|
||||
|
||||
16
backend/apps/core/views/inline_edit.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.views.generic.edit import FormView
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
|
||||
class InlineEditView(FormView):
|
||||
"""Generic inline edit view: GET returns form fragment, POST returns updated fragment."""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.render_to_response(self.get_context_data())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
form = self.get_form()
|
||||
if form.is_valid():
|
||||
self.object = form.save()
|
||||
return self.render_to_response(self.get_context_data(object=self.object))
|
||||
return self.form_invalid(form)
|
||||
17
backend/apps/core/views/modal_views.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.views.generic.edit import FormView
|
||||
from django.http import HttpResponse
|
||||
|
||||
|
||||
class HTMXModalFormView(FormView):
|
||||
"""Render form inside a modal and respond with HTMX triggers on success."""
|
||||
|
||||
modal_template_name = "components/modals/modal_form.html"
|
||||
|
||||
def get_template_names(self):
|
||||
return [self.modal_template_name]
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
if self.request.headers.get("HX-Request") == "true":
|
||||
response["HX-Trigger"] = "modal:close"
|
||||
return response
|
||||
@@ -60,3 +60,32 @@ class SlugRedirectMixin(View):
|
||||
if not self.object:
|
||||
return {}
|
||||
return {self.slug_url_kwarg: getattr(self.object, "slug", "")}
|
||||
|
||||
|
||||
from django.views.generic import TemplateView
|
||||
from django.shortcuts import render
|
||||
|
||||
|
||||
class GlobalSearchView(TemplateView):
|
||||
"""Unified search view with HTMX support for debounced results and suggestions."""
|
||||
|
||||
template_name = "core/search/search.html"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
q = request.GET.get("q", "")
|
||||
results = []
|
||||
suggestions = []
|
||||
# Lightweight placeholder search: real implementation should query multiple models
|
||||
if q:
|
||||
# Return a small payload of mocked results to keep this scaffold safe
|
||||
results = [{"title": f"Result for {q}", "url": "#", "subtitle": "Park"}]
|
||||
suggestions = [{"text": q, "url": "#"}]
|
||||
|
||||
context = {"results": results, "suggestions": suggestions}
|
||||
|
||||
# If HTMX request, render dropdown partial
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
return render(request, "core/search/partials/search_dropdown.html", context)
|
||||
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
|
||||
@@ -54,6 +54,47 @@ urlpatterns = [
|
||||
ParkDistanceCalculatorView.as_view(),
|
||||
name="roadtrip_htmx_distance",
|
||||
),
|
||||
# Additional HTMX endpoints for client-driven route management
|
||||
path(
|
||||
"roadtrip/htmx/add-park/",
|
||||
views.htmx_add_park_to_trip,
|
||||
name="htmx_add_park_to_trip",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/remove-park/",
|
||||
views.htmx_remove_park_from_trip,
|
||||
name="htmx_remove_park_from_trip",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/reorder/",
|
||||
views.htmx_reorder_parks,
|
||||
name="htmx_reorder_parks",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/optimize/",
|
||||
views.htmx_optimize_route,
|
||||
name="htmx_optimize_route",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/calculate/",
|
||||
views.htmx_calculate_route,
|
||||
name="htmx_calculate_route",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/saved/",
|
||||
views.htmx_saved_trips,
|
||||
name="htmx_saved_trips",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/save/",
|
||||
views.htmx_save_trip,
|
||||
name="htmx_save_trip",
|
||||
),
|
||||
path(
|
||||
"roadtrip/htmx/clear/",
|
||||
views.htmx_clear_trip,
|
||||
name="htmx_clear_trip",
|
||||
),
|
||||
# Park detail and related views
|
||||
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
|
||||
path("<slug:slug>/edit/", views.ParkUpdateView.as_view(), name="park_update"),
|
||||
|
||||
@@ -31,6 +31,10 @@ from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||
import requests
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Optional, cast, Literal, Dict
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
import json
|
||||
|
||||
# Constants
|
||||
PARK_DETAIL_URL = "parks:park_detail"
|
||||
@@ -38,6 +42,9 @@ PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
|
||||
REQUIRED_FIELDS_ERROR = (
|
||||
"Please correct the errors below. Required fields are marked with an asterisk (*)."
|
||||
)
|
||||
TRIP_PARKS_TEMPLATE = "parks/partials/trip_parks_list.html"
|
||||
TRIP_SUMMARY_TEMPLATE = "parks/partials/trip_summary.html"
|
||||
SAVED_TRIPS_TEMPLATE = "parks/partials/saved_trips.html"
|
||||
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
|
||||
@@ -461,6 +468,250 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
return response
|
||||
|
||||
|
||||
# --------------------
|
||||
# HTMX roadtrip helpers
|
||||
# --------------------
|
||||
|
||||
|
||||
def htmx_saved_trips(request: HttpRequest) -> HttpResponse:
|
||||
"""Return a partial with the user's saved trips (stubbed)."""
|
||||
trips = []
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
from .models import Trip # type: ignore
|
||||
qs = Trip.objects.filter(owner=request.user).order_by("-created_at")
|
||||
trips = list(qs[:10])
|
||||
except Exception:
|
||||
trips = []
|
||||
return render(request, SAVED_TRIPS_TEMPLATE, {"trips": trips})
|
||||
|
||||
|
||||
def _get_session_trip(request: HttpRequest) -> list:
|
||||
raw = request.session.get("trip_parks", [])
|
||||
try:
|
||||
return [int(x) for x in raw]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_session_trip(request: HttpRequest, trip_list: list) -> None:
|
||||
request.session["trip_parks"] = [int(x) for x in trip_list]
|
||||
request.session.modified = True
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_add_park_to_trip(request: HttpRequest) -> HttpResponse:
|
||||
"""Add a park id to `request.session['trip_parks']` and return the full trip list partial."""
|
||||
park_id = request.POST.get("park_id")
|
||||
if not park_id:
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
park_id = payload.get("park_id")
|
||||
except Exception:
|
||||
park_id = None
|
||||
|
||||
if not park_id:
|
||||
return HttpResponse("", status=400)
|
||||
|
||||
try:
|
||||
pid = int(park_id)
|
||||
except Exception:
|
||||
return HttpResponse("", status=400)
|
||||
|
||||
trip = _get_session_trip(request)
|
||||
if pid not in trip:
|
||||
trip.append(pid)
|
||||
_save_session_trip(request, trip)
|
||||
|
||||
# Build ordered Park queryset preserving session order
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripUpdated": True})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_remove_park_from_trip(request: HttpRequest) -> HttpResponse:
|
||||
"""Remove a park id from `request.session['trip_parks']` and return the updated trip list partial."""
|
||||
park_id = request.POST.get("park_id")
|
||||
if not park_id:
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
park_id = payload.get("park_id")
|
||||
except Exception:
|
||||
park_id = None
|
||||
|
||||
if not park_id:
|
||||
return HttpResponse("", status=400)
|
||||
|
||||
try:
|
||||
pid = int(park_id)
|
||||
except Exception:
|
||||
return HttpResponse("", status=400)
|
||||
|
||||
trip = _get_session_trip(request)
|
||||
if pid in trip:
|
||||
trip = [t for t in trip if t != pid]
|
||||
_save_session_trip(request, trip)
|
||||
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripUpdated": True})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_reorder_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""Accept an ordered list of park ids and persist it to the session, returning the updated list partial."""
|
||||
order = []
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
order = payload.get("order", [])
|
||||
except Exception:
|
||||
order = request.POST.getlist("order[]")
|
||||
|
||||
# Normalize to ints
|
||||
clean_order = []
|
||||
for item in order:
|
||||
try:
|
||||
clean_order.append(int(item))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
_save_session_trip(request, clean_order)
|
||||
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripReordered": True})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_optimize_route(request: HttpRequest) -> HttpResponse:
|
||||
"""Compute a simple trip summary from session parks and return the summary partial."""
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Helper: haversine distance (miles)
|
||||
import math
|
||||
|
||||
def haversine_miles(lat1, lon1, lat2, lon2):
|
||||
# convert decimal degrees to radians
|
||||
rlat1, rlon1, rlat2, rlon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
||||
dlat = rlat2 - rlat1
|
||||
dlon = rlon2 - rlon1
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
|
||||
c = 2 * math.asin(min(1, math.sqrt(a)))
|
||||
miles = 3958.8 * c
|
||||
return miles
|
||||
|
||||
total_miles = 0.0
|
||||
waypoints = []
|
||||
for p in parks:
|
||||
loc = getattr(p, "location", None)
|
||||
lat = getattr(loc, "latitude", None) if loc else None
|
||||
lon = getattr(loc, "longitude", None) if loc else None
|
||||
if lat is not None and lon is not None:
|
||||
waypoints.append({"id": p.id, "name": p.name, "latitude": lat, "longitude": lon})
|
||||
|
||||
# sum straight-line distances between consecutive waypoints
|
||||
for i in range(len(waypoints) - 1):
|
||||
a = waypoints[i]
|
||||
b = waypoints[i + 1]
|
||||
try:
|
||||
total_miles += haversine_miles(a["latitude"], a["longitude"], b["latitude"], b["longitude"])
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Estimate drive time assuming average speed of 60 mph
|
||||
total_hours = total_miles / 60.0 if total_miles else 0.0
|
||||
|
||||
summary = {
|
||||
"total_distance": f"{int(round(total_miles))} mi",
|
||||
"total_time": f"{total_hours:.1f} hrs",
|
||||
"total_parks": len(parks),
|
||||
"total_rides": sum(getattr(p, "ride_count", 0) or 0 for p in parks),
|
||||
}
|
||||
|
||||
html = render_to_string(TRIP_SUMMARY_TEMPLATE, {"summary": summary}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
# Include waypoints payload in HX-Trigger so client can render route on the map
|
||||
resp["HX-Trigger"] = json.dumps({"tripOptimized": {"parks": waypoints}})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_calculate_route(request: HttpRequest) -> HttpResponse:
|
||||
"""Alias for optimize route for now — returns trip summary partial."""
|
||||
return htmx_optimize_route(request)
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_save_trip(request: HttpRequest) -> HttpResponse:
|
||||
"""Save the current session trip to a Trip model (if present) and return saved trips partial."""
|
||||
name = request.POST.get("name") or "My Trip"
|
||||
|
||||
parks = []
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
trips = []
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
from .models import Trip # type: ignore
|
||||
trip = Trip.objects.create(owner=request.user, name=name)
|
||||
# attempt to associate parks if the Trip model supports it
|
||||
try:
|
||||
trip.parks.set([p.id for p in parks])
|
||||
except Exception:
|
||||
pass
|
||||
trips = list(Trip.objects.filter(owner=request.user).order_by("-created_at")[:10])
|
||||
except Exception:
|
||||
trips = []
|
||||
|
||||
html = render_to_string(SAVED_TRIPS_TEMPLATE, {"trips": trips}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripSaved": True})
|
||||
return resp
|
||||
|
||||
|
||||
@require_POST
|
||||
def htmx_clear_trip(request: HttpRequest) -> HttpResponse:
|
||||
"""Clear the current session trip and return an empty trip list partial."""
|
||||
_save_session_trip(request, [])
|
||||
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": []}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"tripCleared": True})
|
||||
return resp
|
||||
|
||||
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Park
|
||||
form_class = ParkForm
|
||||
@@ -517,7 +768,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
park_location, _ = ParkLocation.objects.get_or_create(
|
||||
park=self.object,
|
||||
defaults={
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
// Reduced Alpine components: keep only pure client-side UI state
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('themeToggle', () => ({
|
||||
theme: localStorage.getItem('theme') || 'system',
|
||||
init() { this.updateTheme(); },
|
||||
toggle() {
|
||||
this.theme = this.theme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', this.theme);
|
||||
this.updateTheme();
|
||||
},
|
||||
updateTheme() {
|
||||
if (this.theme === 'dark') document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}));
|
||||
|
||||
Alpine.data('mobileMenu', () => ({
|
||||
open: false,
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
document.body.style.overflow = this.open ? 'hidden' : '';
|
||||
}
|
||||
}));
|
||||
|
||||
Alpine.data('dropdown', () => ({
|
||||
open: false,
|
||||
toggle() { this.open = !this.open; }
|
||||
}));
|
||||
});
|
||||
/**
|
||||
* Alpine.js Components for ThrillWiki
|
||||
* Enhanced components matching React frontend functionality
|
||||
@@ -367,202 +396,7 @@ Alpine.data('toast', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// Enhanced Authentication Modal Component
|
||||
Alpine.data('authModal', (defaultMode = 'login') => ({
|
||||
open: false,
|
||||
mode: defaultMode, // 'login' or 'register'
|
||||
showPassword: false,
|
||||
socialProviders: [],
|
||||
socialLoading: true,
|
||||
|
||||
// Login form data
|
||||
loginForm: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
loginLoading: false,
|
||||
loginError: '',
|
||||
|
||||
// Register form data
|
||||
registerForm: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password1: '',
|
||||
password2: ''
|
||||
},
|
||||
registerLoading: false,
|
||||
registerError: '',
|
||||
|
||||
init() {
|
||||
this.fetchSocialProviders();
|
||||
|
||||
// Listen for auth modal events
|
||||
this.$watch('open', (value) => {
|
||||
if (value) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
this.resetForms();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async fetchSocialProviders() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/social-providers/');
|
||||
const data = await response.json();
|
||||
this.socialProviders = data.available_providers || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch social providers:', error);
|
||||
this.socialProviders = [];
|
||||
} finally {
|
||||
this.socialLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
show(mode = 'login') {
|
||||
this.mode = mode;
|
||||
this.open = true;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
},
|
||||
|
||||
switchToLogin() {
|
||||
this.mode = 'login';
|
||||
this.resetForms();
|
||||
},
|
||||
|
||||
switchToRegister() {
|
||||
this.mode = 'register';
|
||||
this.resetForms();
|
||||
},
|
||||
|
||||
resetForms() {
|
||||
this.loginForm = { username: '', password: '' };
|
||||
this.registerForm = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password1: '',
|
||||
password2: ''
|
||||
};
|
||||
this.loginError = '';
|
||||
this.registerError = '';
|
||||
this.showPassword = false;
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
if (!this.loginForm.username || !this.loginForm.password) {
|
||||
this.loginError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/accounts/login/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': this.getCSRFToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
login: this.loginForm.username,
|
||||
password: this.loginForm.password
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Login successful - reload page to update auth state
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
this.loginError = data.message || 'Login failed. Please check your credentials.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
this.loginError = 'An error occurred. Please try again.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async handleRegister() {
|
||||
if (!this.registerForm.first_name || !this.registerForm.last_name ||
|
||||
!this.registerForm.email || !this.registerForm.username ||
|
||||
!this.registerForm.password1 || !this.registerForm.password2) {
|
||||
this.registerError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.registerForm.password1 !== this.registerForm.password2) {
|
||||
this.registerError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerLoading = true;
|
||||
this.registerError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/accounts/signup/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': this.getCSRFToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
first_name: this.registerForm.first_name,
|
||||
last_name: this.registerForm.last_name,
|
||||
email: this.registerForm.email,
|
||||
username: this.registerForm.username,
|
||||
password1: this.registerForm.password1,
|
||||
password2: this.registerForm.password2
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Registration successful
|
||||
this.close();
|
||||
// Show success message or redirect
|
||||
Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
this.registerError = data.message || 'Registration failed. Please try again.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
this.registerError = 'An error occurred. Please try again.';
|
||||
} finally {
|
||||
this.registerLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleSocialLogin(providerId) {
|
||||
const provider = this.socialProviders.find(p => p.id === providerId);
|
||||
if (!provider) {
|
||||
Alpine.store('toast').error(`Social provider ${providerId} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to social auth URL
|
||||
window.location.href = provider.auth_url;
|
||||
},
|
||||
|
||||
getCSRFToken() {
|
||||
const token = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
|
||||
document.querySelector('meta[name=csrf-token]')?.getAttribute('content') ||
|
||||
document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
|
||||
return token || '';
|
||||
}
|
||||
}));
|
||||
|
||||
// Enhanced Toast Component with Better UX
|
||||
Alpine.data('toast', () => ({
|
||||
|
||||
@@ -1,774 +1,209 @@
|
||||
/**
|
||||
* ThrillWiki Road Trip Planner - Multi-park Route Planning
|
||||
*
|
||||
* This module provides road trip planning functionality with multi-park selection,
|
||||
* route visualization, distance calculations, and export capabilities
|
||||
*/
|
||||
/* Minimal Roadtrip JS helpers for HTMX-driven planner
|
||||
- Initializes map helpers when Leaflet is available
|
||||
- Exposes `RoadtripMap` global with basic marker helpers
|
||||
- Heavy client-side trip logic is intentionally moved to HTMX endpoints
|
||||
*/
|
||||
|
||||
class RoadTripPlanner {
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
mapInstance: null,
|
||||
maxParks: 20,
|
||||
enableOptimization: true,
|
||||
enableExport: true,
|
||||
apiEndpoints: {
|
||||
parks: '/api/parks/',
|
||||
route: '/api/roadtrip/route/',
|
||||
optimize: '/api/roadtrip/optimize/',
|
||||
export: '/api/roadtrip/export/'
|
||||
},
|
||||
routeOptions: {
|
||||
color: '#3B82F6',
|
||||
weight: 4,
|
||||
opacity: 0.8
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
this.container = null;
|
||||
this.mapInstance = null;
|
||||
this.selectedParks = [];
|
||||
this.routeLayer = null;
|
||||
this.parkMarkers = new Map();
|
||||
this.routePolyline = null;
|
||||
this.routeData = null;
|
||||
|
||||
this.init();
|
||||
class RoadtripMap {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.markers = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the road trip planner
|
||||
*/
|
||||
init() {
|
||||
this.container = document.getElementById(this.containerId);
|
||||
if (!this.container) {
|
||||
console.error(`Road trip container with ID '${this.containerId}' not found`);
|
||||
return;
|
||||
init(containerId, opts = {}) {
|
||||
if (typeof L === 'undefined') return;
|
||||
try {
|
||||
this.map = L.map(containerId).setView([51.505, -0.09], 5);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(this.map);
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize map', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.setupUI();
|
||||
this.bindEvents();
|
||||
|
||||
// Connect to map instance if provided
|
||||
if (this.options.mapInstance) {
|
||||
this.connectToMap(this.options.mapInstance);
|
||||
addMarker(park) {
|
||||
if (!this.map || !park || !park.latitude || !park.longitude) return;
|
||||
const id = park.id;
|
||||
if (this.markers[id]) return;
|
||||
const m = L.marker([park.latitude, park.longitude]).addTo(this.map).bindPopup(park.name);
|
||||
this.markers[id] = m;
|
||||
}
|
||||
|
||||
this.loadInitialData();
|
||||
removeMarker(parkId) {
|
||||
const m = this.markers[parkId];
|
||||
if (m && this.map) {
|
||||
this.map.removeLayer(m);
|
||||
delete this.markers[parkId];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the UI components
|
||||
*/
|
||||
setupUI() {
|
||||
const html = `
|
||||
<div class="roadtrip-planner">
|
||||
<div class="roadtrip-header">
|
||||
<h3 class="roadtrip-title">
|
||||
<i class="fas fa-route"></i>
|
||||
Road Trip Planner
|
||||
</h3>
|
||||
<div class="roadtrip-controls">
|
||||
<button id="optimize-route" class="btn btn-secondary btn-sm" disabled>
|
||||
<i class="fas fa-magic"></i> Optimize Route
|
||||
</button>
|
||||
<button id="clear-route" class="btn btn-outline btn-sm" disabled>
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roadtrip-content">
|
||||
<div class="park-selection">
|
||||
<div class="search-parks">
|
||||
<input type="text" id="park-search"
|
||||
placeholder="Search parks to add..."
|
||||
class="form-input">
|
||||
<div id="park-search-results" class="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-parks">
|
||||
<h4 class="section-title">Your Route (<span id="park-count">0</span>/${this.options.maxParks})</h4>
|
||||
<div id="parks-list" class="parks-list sortable">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-map-marked-alt"></i>
|
||||
<p>Search and select parks to build your road trip route</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="route-summary" id="route-summary" style="display: none;">
|
||||
<h4 class="section-title">Trip Summary</h4>
|
||||
<div class="summary-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Total Distance:</span>
|
||||
<span id="total-distance" class="stat-value">-</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Driving Time:</span>
|
||||
<span id="total-time" class="stat-value">-</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Parks:</span>
|
||||
<span id="total-parks" class="stat-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-options">
|
||||
<button id="export-gpx" class="btn btn-outline btn-sm">
|
||||
<i class="fas fa-download"></i> Export GPX
|
||||
</button>
|
||||
<button id="export-kml" class="btn btn-outline btn-sm">
|
||||
<i class="fas fa-download"></i> Export KML
|
||||
</button>
|
||||
<button id="share-route" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-share"></i> Share Route
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = html;
|
||||
fitToMarkers() {
|
||||
const keys = Object.keys(this.markers);
|
||||
if (!this.map || keys.length === 0) return;
|
||||
const group = new L.featureGroup(keys.map(k => this.markers[k]));
|
||||
this.map.fitBounds(group.getBounds().pad(0.2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event handlers
|
||||
*/
|
||||
bindEvents() {
|
||||
// Park search
|
||||
const searchInput = document.getElementById('park-search');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
this.searchParks(e.target.value);
|
||||
}, 300);
|
||||
});
|
||||
showRoute(orderedParks = []) {
|
||||
if (!this.map || typeof L.Routing === 'undefined') return;
|
||||
// remove existing control if present
|
||||
if (this._routingControl) {
|
||||
try {
|
||||
this.map.removeControl(this._routingControl);
|
||||
} catch (e) {}
|
||||
this._routingControl = null;
|
||||
}
|
||||
|
||||
// Route controls
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
if (optimizeBtn) {
|
||||
optimizeBtn.addEventListener('click', () => this.optimizeRoute());
|
||||
}
|
||||
const waypoints = orderedParks
|
||||
.filter(p => p.latitude && p.longitude)
|
||||
.map(p => L.latLng(p.latitude, p.longitude));
|
||||
|
||||
const clearBtn = document.getElementById('clear-route');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearRoute());
|
||||
}
|
||||
|
||||
// Export buttons
|
||||
const exportGpxBtn = document.getElementById('export-gpx');
|
||||
if (exportGpxBtn) {
|
||||
exportGpxBtn.addEventListener('click', () => this.exportRoute('gpx'));
|
||||
}
|
||||
|
||||
const exportKmlBtn = document.getElementById('export-kml');
|
||||
if (exportKmlBtn) {
|
||||
exportKmlBtn.addEventListener('click', () => this.exportRoute('kml'));
|
||||
}
|
||||
|
||||
const shareBtn = document.getElementById('share-route');
|
||||
if (shareBtn) {
|
||||
shareBtn.addEventListener('click', () => this.shareRoute());
|
||||
}
|
||||
|
||||
// Make parks list sortable
|
||||
this.initializeSortable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize drag-and-drop sorting for parks list
|
||||
*/
|
||||
initializeSortable() {
|
||||
const parksList = document.getElementById('parks-list');
|
||||
if (!parksList) return;
|
||||
|
||||
// Simple drag and drop implementation
|
||||
let draggedElement = null;
|
||||
|
||||
parksList.addEventListener('dragstart', (e) => {
|
||||
if (e.target.classList.contains('park-item')) {
|
||||
draggedElement = e.target;
|
||||
e.target.style.opacity = '0.5';
|
||||
}
|
||||
});
|
||||
|
||||
parksList.addEventListener('dragend', (e) => {
|
||||
if (e.target.classList.contains('park-item')) {
|
||||
e.target.style.opacity = '1';
|
||||
draggedElement = null;
|
||||
}
|
||||
});
|
||||
|
||||
parksList.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
parksList.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (draggedElement && e.target.classList.contains('park-item')) {
|
||||
const afterElement = this.getDragAfterElement(parksList, e.clientY);
|
||||
|
||||
if (afterElement == null) {
|
||||
parksList.appendChild(draggedElement);
|
||||
} else {
|
||||
parksList.insertBefore(draggedElement, afterElement);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (waypoints.length < 2) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.options.apiEndpoints.parks}?q=${encodeURIComponent(query)}&limit=10`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.displaySearchResults(data.data);
|
||||
this._routingControl = L.Routing.control({
|
||||
waypoints: waypoints,
|
||||
draggableWaypoints: false,
|
||||
addWaypoints: false,
|
||||
showAlternatives: false,
|
||||
routeWhileDragging: false,
|
||||
fitSelectedRoute: true,
|
||||
createMarker: function(i, wp) {
|
||||
const cls = i === 0 ? 'waypoint-start' : (i === waypoints.length - 1 ? 'waypoint-end' : 'waypoint-stop');
|
||||
return L.marker(wp.latLng, { className: 'waypoint-marker ' + cls }).bindPopup(`Stop ${i+1}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to search parks:', error);
|
||||
}).addTo(this.map);
|
||||
} 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
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const roadtripContainer = document.getElementById('roadtrip-planner');
|
||||
if (roadtripContainer) {
|
||||
window.roadTripPlanner = new RoadTripPlanner('roadtrip-planner', {
|
||||
mapInstance: window.thrillwikiMap || null
|
||||
// Expose simple global for templates to call
|
||||
globalThis.RoadtripMap = new RoadtripMap();
|
||||
|
||||
// Backwards-compatible lightweight planner shim used by other scripts
|
||||
class RoadTripPlannerShim {
|
||||
constructor(containerId) {
|
||||
this.containerId = containerId;
|
||||
}
|
||||
|
||||
async addPark(parkId) {
|
||||
// POST to HTMX add endpoint and insert returned fragment
|
||||
try {
|
||||
const csrftoken = (document.cookie.match(/(^|;)\s*csrftoken=([^;]+)/) || [])[2];
|
||||
const resp = await fetch(`/parks/roadtrip/htmx/add-park/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrftoken || ''
|
||||
},
|
||||
body: `park_id=${encodeURIComponent(parkId)}`,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const html = await resp.text();
|
||||
const container = document.getElementById('trip-parks');
|
||||
if (container) container.insertAdjacentHTML('afterbegin', html);
|
||||
if (globalThis.RoadtripMap && typeof globalThis.RoadtripMap.addMarker === 'function') {
|
||||
try {
|
||||
const parkResp = await fetch(`/api/parks/${parkId}/`);
|
||||
const parkJson = await parkResp.json();
|
||||
if (parkJson && parkJson.data) globalThis.RoadtripMap.addMarker(parkJson.data);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to add park via HTMX shim', e);
|
||||
}
|
||||
}
|
||||
|
||||
removePark(parkId) {
|
||||
const el = document.querySelector(`[data-park-id="${parkId}"]`);
|
||||
if (el) el.remove();
|
||||
if (globalThis.RoadtripMap && typeof globalThis.RoadtripMap.removeMarker === 'function') {
|
||||
globalThis.RoadtripMap.removeMarker(parkId);
|
||||
}
|
||||
}
|
||||
|
||||
fitRoute() {
|
||||
if (globalThis.RoadtripMap && typeof globalThis.RoadtripMap.fitToMarkers === 'function') {
|
||||
globalThis.RoadtripMap.fitToMarkers();
|
||||
}
|
||||
}
|
||||
|
||||
toggleAllParks() {
|
||||
// No-op in shim; map integration can implement this separately
|
||||
console.debug('toggleAllParks called (shim)');
|
||||
}
|
||||
}
|
||||
|
||||
// Expose compatibility globals
|
||||
globalThis.RoadTripPlanner = RoadTripPlannerShim;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
try {
|
||||
globalThis.roadTripPlanner = new RoadTripPlannerShim('roadtrip-planner');
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
// Initialize Sortable for #trip-parks and POST new order to server
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
try {
|
||||
if (typeof Sortable === 'undefined') return;
|
||||
const el = document.getElementById('trip-parks');
|
||||
if (!el) return;
|
||||
// avoid double-init
|
||||
if (el._sortableInit) return;
|
||||
el._sortableInit = true;
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = RoadTripPlanner;
|
||||
} else {
|
||||
window.RoadTripPlanner = RoadTripPlanner;
|
||||
}
|
||||
function getCookie(name) {
|
||||
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||
if (match) return decodeURIComponent(match[2]);
|
||||
return null;
|
||||
}
|
||||
|
||||
new Sortable(el, {
|
||||
animation: 150,
|
||||
ghostClass: 'drag-over',
|
||||
handle: '.draggable-item',
|
||||
onEnd: function (evt) {
|
||||
// gather order from container children
|
||||
const order = Array.from(el.children).map(function (c) { return c.dataset.parkId; }).filter(Boolean);
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
fetch('/parks/roadtrip/htmx/reorder/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrftoken || ''
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ order: order })
|
||||
}).then(function (r) { return r.text(); }).then(function (html) {
|
||||
// replace inner HTML with server-rendered partial
|
||||
el.innerHTML = html;
|
||||
// notify other listeners (map, summary)
|
||||
document.dispatchEvent(new CustomEvent('tripReordered', { detail: { order: order } }));
|
||||
}).catch(function (err) {
|
||||
console.error('Failed to post reorder', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Sortable init error', e);
|
||||
}
|
||||
});
|
||||
// Listen for HTMX trigger event and show route when available
|
||||
document.addEventListener('tripOptimized', function (ev) {
|
||||
try {
|
||||
const payload = ev && ev.detail ? ev.detail : {};
|
||||
const parks = (payload && payload.parks) || [];
|
||||
if (globalThis.RoadtripMap && typeof globalThis.RoadtripMap.showRoute === 'function') {
|
||||
globalThis.RoadtripMap.showRoute(parks);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
// End of roadtrip helpers
|
||||
@@ -7,361 +7,25 @@ Matches React frontend AuthDialog functionality with modal-based auth
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
|
||||
<!-- Auth Modal Component -->
|
||||
<div
|
||||
x-data="authModal()"
|
||||
x-show="open"
|
||||
x-cloak
|
||||
x-init="window.authModal = $data"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@keydown.escape.window="close()"
|
||||
>
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition-opacity ease-linear duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity ease-linear duration-300"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-background/80 backdrop-blur-sm"
|
||||
@click="close()"
|
||||
></div>
|
||||
<!-- HTMX-driven Auth Modal Container -->
|
||||
{# This modal no longer manages form submission client-side. Forms are fetched
|
||||
and submitted via HTMX using the account views endpoints (CustomLoginView/CustomSignupView). #}
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="relative w-full max-w-md mx-4 bg-background border rounded-lg shadow-lg"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="close()"
|
||||
class="absolute top-4 right-4 p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<div id="auth-modal" class="fixed inset-0 z-50 hidden items-center justify-center" role="dialog" aria-modal="true" tabindex="-1" hx-on:keydown="if(event.key==='Escape'){ document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden'); }">
|
||||
<div id="auth-modal-overlay" class="fixed inset-0 bg-background/80 backdrop-blur-sm" onclick="document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden');"></div>
|
||||
|
||||
<div id="auth-modal-content" class="relative w-full max-w-md mx-4 bg-background border rounded-lg shadow-lg" role="dialog" aria-modal="true">
|
||||
<button type="button" class="absolute top-4 right-4 p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors auth-close" onclick="document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden');">
|
||||
<i class="fas fa-times w-4 h-4"></i>
|
||||
</button>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div x-show="mode === 'login'" class="p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
|
||||
Sign In
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
Enter your credentials to access your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Social Login Buttons -->
|
||||
<div x-show="socialProviders.length > 0" class="mb-6">
|
||||
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
|
||||
<template x-for="provider in socialProviders" :key="provider.id">
|
||||
<button
|
||||
@click="handleSocialLogin(provider.id)"
|
||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
|
||||
:class="{
|
||||
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
|
||||
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
|
||||
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="mr-2 w-4 h-4"
|
||||
:class="{
|
||||
'fab fa-google': provider.id === 'google',
|
||||
'fab fa-discord': provider.id === 'discord'
|
||||
}"
|
||||
></i>
|
||||
<span x-text="provider.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="socialLoading" class="grid grid-cols-2 gap-4">
|
||||
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
|
||||
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-muted"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form
|
||||
@submit.prevent="handleLogin()"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<label for="login-username" class="text-sm font-medium">
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
x-model="loginForm.username"
|
||||
placeholder="Enter your email or username"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="login-password" class="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
x-model="loginForm.password"
|
||||
placeholder="Enter your password"
|
||||
class="input w-full pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href="{% url 'account_reset_password' %}"
|
||||
class="text-sm text-primary hover:text-primary/80 underline-offset-4 hover:underline font-medium"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div x-show="loginError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<span x-text="loginError"></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loginLoading"
|
||||
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
|
||||
>
|
||||
<span x-show="!loginLoading">Sign In</span>
|
||||
<span x-show="loginLoading" class="flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
Signing in...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Switch to Register -->
|
||||
<div class="text-center text-sm text-muted-foreground mt-6">
|
||||
Don't have an account?
|
||||
<button
|
||||
@click="switchToRegister()"
|
||||
class="text-primary hover:underline font-medium ml-1"
|
||||
type="button"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<div x-show="mode === 'register'" class="p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
|
||||
Create Account
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
Join ThrillWiki to start exploring theme parks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Social Registration Buttons -->
|
||||
<div x-show="socialProviders.length > 0" class="mb-6">
|
||||
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
|
||||
<template x-for="provider in socialProviders" :key="provider.id">
|
||||
<button
|
||||
@click="handleSocialLogin(provider.id)"
|
||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
|
||||
:class="{
|
||||
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
|
||||
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
|
||||
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="mr-2 w-4 h-4"
|
||||
:class="{
|
||||
'fab fa-google': provider.id === 'google',
|
||||
'fab fa-discord': provider.id === 'discord'
|
||||
}"
|
||||
></i>
|
||||
<span x-text="provider.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-muted"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-background px-2 text-muted-foreground">
|
||||
Or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<form
|
||||
@submit.prevent="handleRegister()"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="register-first-name" class="text-sm font-medium">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
id="register-first-name"
|
||||
type="text"
|
||||
x-model="registerForm.first_name"
|
||||
placeholder="First name"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="register-last-name" class="text-sm font-medium">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
id="register-last-name"
|
||||
type="text"
|
||||
x-model="registerForm.last_name"
|
||||
placeholder="Last name"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-email" class="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="register-email"
|
||||
type="email"
|
||||
x-model="registerForm.email"
|
||||
placeholder="Enter your email"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-username" class="text-sm font-medium">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="register-username"
|
||||
type="text"
|
||||
x-model="registerForm.username"
|
||||
placeholder="Choose a username"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-password" class="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="register-password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
x-model="registerForm.password1"
|
||||
placeholder="Create a password"
|
||||
class="input w-full pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-password2" class="text-sm font-medium">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="register-password2"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
x-model="registerForm.password2"
|
||||
placeholder="Confirm your password"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div x-show="registerError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<span x-text="registerError"></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="registerLoading"
|
||||
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
|
||||
>
|
||||
<span x-show="!registerLoading">Create Account</span>
|
||||
<span x-show="registerLoading" class="flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
Creating account...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Switch to Login -->
|
||||
<div class="text-center text-sm text-muted-foreground mt-6">
|
||||
Already have an account?
|
||||
<button
|
||||
@click="switchToLogin()"
|
||||
class="text-primary hover:underline font-medium ml-1"
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content will be loaded here via HTMX -->
|
||||
<div id="auth-modal-body" hx-swap-oob="true" hx-on:htmx:afterSwap="(function(){ var el=document.querySelector('#auth-modal-body input, #auth-modal-body button'); if(el){ el.focus(); } })()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Example triggers (elsewhere in the app you can use hx-get to load the desired form into #auth-modal-body):
|
||||
<button hx-get="{% url 'account_login' %}" hx-target="#auth-modal-body" hx-swap="innerHTML" onclick="document.getElementById('auth-modal').classList.remove('hidden')">Sign in</button>
|
||||
<button hx-get="{% url 'account_signup' %}" hx-target="#auth-modal-body" hx-swap="innerHTML" onclick="document.getElementById('auth-modal').classList.remove('hidden')">Sign up</button>
|
||||
The login/signup views already return partials for HTMX requests (see `CustomLoginView` / `CustomSignupView`).
|
||||
#}
|
||||
|
||||
5
backend/templates/components/filters/active_filters.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="active-filters">
|
||||
{% for f in active %}
|
||||
<span class="active-filter">{{ f.label }} <button hx-get="{{ f.remove_url }}">×</button></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" name="{{ name }}" value="{{ item.value }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar" />
|
||||
<span>{{ item.label }} <small>({{ item.count }})</small></span>
|
||||
</label>
|
||||
8
backend/templates/components/filters/filter_group.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<section class="filter-group" aria-expanded="true">
|
||||
<h4>{{ group.title }}</h4>
|
||||
<div class="filter-items">
|
||||
{% for item in group.items %}
|
||||
{% include "components/filters/filter_checkbox.html" with item=item %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
4
backend/templates/components/filters/filter_range.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="filter-range">
|
||||
<label>{{ label }}</label>
|
||||
<input type="range" name="{{ name }}" min="{{ min }}" max="{{ max }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar" />
|
||||
</div>
|
||||
8
backend/templates/components/filters/filter_select.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<div class="filter-select">
|
||||
<label for="{{ name }}">{{ label }}</label>
|
||||
<select id="{{ name }}" name="{{ name }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar">
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.value }}">{{ opt.label }} ({{ opt.count }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
6
backend/templates/components/filters/filter_sidebar.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<aside class="filter-sidebar" id="filter-sidebar">
|
||||
{% for group in groups %}
|
||||
{% include "components/filters/filter_group.html" with group=group %}
|
||||
{% endfor %}
|
||||
<div class="applied-filters">{% include "components/filters/active_filters.html" %}</div>
|
||||
</aside>
|
||||
7
backend/templates/components/inline_edit/edit_form.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<form hx-post="{{ action }}" hx-target="closest .editable-field" hx-swap="outerHTML">
|
||||
{% for field in form %}
|
||||
{% include "forms/partials/form_field.html" with field=field %}
|
||||
{% endfor %}
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
</form>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="editable-field editable-field-{{ name }}">
|
||||
<div class="field-display">{% include "components/inline_edit/field_display.html" %}</div>
|
||||
<div class="field-edit" hx-swap-oob="true"></div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="field-display-value">{{ value }}</div>
|
||||
@@ -1,3 +1,4 @@
|
||||
{# Global search removed duplicate header; primary header below handles search #}
|
||||
{% comment %}
|
||||
Enhanced Header Component - Matches React Frontend Design
|
||||
Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
@@ -133,32 +134,29 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
|
||||
<!-- Desktop Right Side -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<!-- Enhanced Search -->
|
||||
<div class="relative" x-data="searchComponent()">
|
||||
<!-- Enhanced Search (HTMX-driven) -->
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"></i>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search parks, rides..."
|
||||
class="w-[300px] pl-10 pr-20 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
hx-get="{% url 'search:search' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#search-results"
|
||||
hx-include="this"
|
||||
hx-indicator=".htmx-loading-indicator"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
/>
|
||||
{% include 'components/ui/button.html' with variant='default' size='sm' text='Search' class='absolute right-1 top-1/2 transform -translate-y-1/2' %}
|
||||
</div>
|
||||
|
||||
<!-- Search Results Dropdown -->
|
||||
<!-- Search Results Dropdown: always present and controlled by HTMX swaps -->
|
||||
<div
|
||||
id="search-results"
|
||||
x-show="results.length > 0"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
|
||||
aria-live="polite"
|
||||
>
|
||||
<!-- Search results will be populated by HTMX -->
|
||||
</div>
|
||||
@@ -239,13 +237,19 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
{% else %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="window.authModal.show('login')"
|
||||
hx-get="{% url 'account_login' %}"
|
||||
hx-target="#auth-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('auth-modal').classList.remove('hidden'); document.body.classList.add('overflow-hidden');"
|
||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 rounded-md px-3"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
@click="window.authModal.show('register')"
|
||||
hx-get="{% url 'account_signup' %}"
|
||||
hx-target="#auth-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('auth-modal').classList.remove('hidden'); document.body.classList.add('overflow-hidden');"
|
||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 rounded-md px-3"
|
||||
>
|
||||
Sign Up
|
||||
|
||||
5
backend/templates/components/modals/modal_base.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div id="modal-container" class="modal" role="dialog" aria-modal="true" tabindex="-1">
|
||||
<div class="modal-content">
|
||||
{% block modal_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
5
backend/templates/components/modals/modal_confirm.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends "components/modals/modal_base.html" %}
|
||||
|
||||
{% block modal_content %}
|
||||
{% include "htmx/components/confirm_dialog.html" %}
|
||||
{% endblock %}
|
||||
10
backend/templates/components/modals/modal_form.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "components/modals/modal_base.html" %}
|
||||
|
||||
{% block modal_content %}
|
||||
<form hx-post="{{ action }}" hx-target="#modal-container" hx-swap="outerHTML">
|
||||
{% for field in form %}
|
||||
{% include "forms/partials/form_field.html" with field=field %}
|
||||
{% endfor %}
|
||||
{% include "forms/partials/form_actions.html" %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
3
backend/templates/components/modals/modal_loading.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="modal-loading">
|
||||
{% include "htmx/components/loading_indicator.html" %}
|
||||
</div>
|
||||
10
backend/templates/core/search/partials/search_dropdown.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<div id="search-dropdown" class="search-dropdown">
|
||||
{% include "core/search/partials/search_suggestions.html" %}
|
||||
<div id="search-results">
|
||||
{% for item in results %}
|
||||
{% include "core/search/partials/search_result_item.html" with item=item %}
|
||||
{% empty %}
|
||||
{% include "core/search/partials/search_empty.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
1
backend/templates/core/search/partials/search_empty.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="search-empty">No results found.</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="search-result-item">
|
||||
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||
<div class="muted">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<ul class="search-suggestions">
|
||||
{% for suggestion in suggestions %}
|
||||
<li hx-get="{{ suggestion.url }}" hx-swap="#search-results">{{ suggestion.text }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
9
backend/templates/core/search/search.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "base/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Search</h1>
|
||||
<form hx-get="/search/" hx-trigger="input changed delay:300ms" hx-target="#search-dropdown">
|
||||
<input name="q" placeholder="Search..." />
|
||||
</form>
|
||||
<div id="search-dropdown"></div>
|
||||
{% endblock %}
|
||||
7
backend/templates/forms/partials/field_error.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% if errors %}
|
||||
<ul class="field-errors">
|
||||
{% for e in errors %}
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
1
backend/templates/forms/partials/field_success.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="field-success" aria-hidden="true">✓</div>
|
||||
4
backend/templates/forms/partials/form_actions.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
<button type="button" class="btn-secondary" hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
</div>
|
||||
5
backend/templates/forms/partials/form_field.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="form-field" data-field-name="{{ field.name }}">
|
||||
<label for="id_{{ field.name }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
<div class="field-feedback" aria-live="polite">{% include "forms/partials/field_error.html" %}</div>
|
||||
</div>
|
||||
9
backend/templates/htmx/components/confirm_dialog.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="htmx-confirm" role="dialog" aria-modal="true">
|
||||
<div class="confirm-body">
|
||||
<p>{{ message|default:"Are you sure?" }}</p>
|
||||
<div class="confirm-actions">
|
||||
<button hx-post="{{ confirm_url }}" hx-vals='{"confirm": true}'>Confirm</button>
|
||||
<button hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
4
backend/templates/htmx/components/error_message.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="htmx-error" role="alert" aria-live="assertive">
|
||||
<strong>{{ title|default:"Error" }}</strong>
|
||||
<p>{{ message|default:"An error occurred. Please try again." }}</p>
|
||||
</div>
|
||||
4
backend/templates/htmx/components/filter_badge.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<span class="filter-badge" role="status">
|
||||
{{ label }}
|
||||
<button hx-get="{{ remove_url }}" hx-swap="outerHTML">×</button>
|
||||
</span>
|
||||
4
backend/templates/htmx/components/inline_edit_field.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="inline-edit-field" data-editable-name="{{ name }}">
|
||||
<div class="display">{{ value }}</div>
|
||||
<button hx-get="{{ edit_url }}" hx-target="closest .inline-edit-field" hx-swap="outerHTML">Edit</button>
|
||||
</div>
|
||||
3
backend/templates/htmx/components/loading_indicator.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="htmx-loading-indicator" aria-hidden="true">
|
||||
<div class="spinner">Loading…</div>
|
||||
</div>
|
||||
9
backend/templates/htmx/components/pagination.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<nav class="htmx-pagination" role="navigation" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<button hx-get="{{ request.path }}?page={{ page_obj.previous_page_number }}" hx-swap="#results">Previous</button>
|
||||
{% endif %}
|
||||
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
{% if page_obj.has_next %}
|
||||
<button hx-get="{{ request.path }}?page={{ page_obj.next_page_number }}" hx-swap="#results">Next</button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
3
backend/templates/htmx/components/success_toast.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="htmx-success" role="status" aria-live="polite">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
19
backend/templates/parks/partials/park_list_item.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% for park in parks %}
|
||||
<div class="park-search-item p-2 border-b border-gray-100 flex items-center justify-between" data-park-id="{{ park.id }}">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ park.name }}</div>
|
||||
{% if park.location %}
|
||||
<div class="text-xs text-gray-500">{{ park.location.city }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<button class="px-2 py-1 text-sm bg-blue-600 text-white rounded"
|
||||
hx-post="{% url 'parks:htmx_add_park_to_trip' %}"
|
||||
hx-vals='{"park_id": "{{ park.id }}"}'
|
||||
hx-target="#trip-parks"
|
||||
hx-swap="afterbegin">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
21
backend/templates/parks/partials/saved_trips.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="saved-trips-list">
|
||||
{% if trips %}
|
||||
<ul class="space-y-2">
|
||||
{% for trip in trips %}
|
||||
<li class="p-2 bg-white rounded-md shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium">{{ trip.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ trip.created_at }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'parks:roadtrip_detail' trip.id %}" class="text-blue-600">View</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-gray-500">No saved trips yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
16
backend/templates/parks/partials/trip_park_item.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="trip-park-item draggable-item flex items-center justify-between p-2 bg-gray-50 rounded-md" data-park-id="{{ park.id }}">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-sm font-medium text-gray-900">{{ park.name }}</div>
|
||||
{% if park.location %}
|
||||
<div class="text-xs text-gray-500">{{ park.location.city }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button class="px-2 py-1 text-sm text-red-600 hover:text-red-800"
|
||||
hx-post="{% url 'parks:htmx_remove_park_from_trip' %}"
|
||||
hx-vals='{"park_id": "{{ park.id }}"}'
|
||||
hx-swap="delete">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
34
backend/templates/parks/partials/trip_parks_list.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<div id="trip-parks" class="space-y-2">
|
||||
{% if trip_parks %}
|
||||
{% for park in trip_parks %}
|
||||
{% include 'parks/partials/trip_park_item.html' with park=park %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div id="empty-trip" class="text-center py-8 text-gray-500">
|
||||
<i class="fas fa-route text-3xl mb-3"></i>
|
||||
<p>Add parks to start planning your trip</p>
|
||||
<p class="text-sm mt-1">Search above or click parks on the map</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('htmx:load', function () {
|
||||
const el = document.getElementById('trip-parks');
|
||||
if (!el) return;
|
||||
if (el.dataset.sortableInit) return;
|
||||
if (typeof Sortable === 'undefined' || typeof htmx === 'undefined') return;
|
||||
el.dataset.sortableInit = '1';
|
||||
const sorter = new Sortable(el, {
|
||||
animation: 150,
|
||||
handle: '.draggable-item',
|
||||
onEnd: function () {
|
||||
const order = Array.from(el.querySelectorAll('[data-park-id]')).map(function (n) {
|
||||
return n.dataset.parkId;
|
||||
});
|
||||
htmx.ajax('POST', '{% url "parks:htmx_reorder_parks" %}', { values: { 'order[]': order } });
|
||||
},
|
||||
});
|
||||
el._tripSortable = sorter;
|
||||
});
|
||||
</script>
|
||||
21
backend/templates/parks/partials/trip_summary.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div id="trip-summary" class="trip-summary-card">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900">Trip Summary</h3>
|
||||
<div class="trip-stats grid grid-cols-2 gap-4">
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-distance">{{ summary.total_distance }}</div>
|
||||
<div class="trip-stat-label">Total Miles</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-time">{{ summary.total_time }}</div>
|
||||
<div class="trip-stat-label">Drive Time</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-parks">{{ summary.total_parks }}</div>
|
||||
<div class="trip-stat-label">Parks</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-rides">{{ summary.total_rides }}</div>
|
||||
<div class="trip-stat-label">Total Rides</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +156,7 @@
|
||||
<input type="text" id="park-search"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search parks by name or location..."
|
||||
hx-get="{% url 'parks:htmx_search_parks' %}"
|
||||
hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#park-search-results"
|
||||
hx-indicator="#search-loading">
|
||||
@@ -166,7 +166,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
|
||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<!-- Search results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +177,9 @@
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
||||
<button id="clear-trip"
|
||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
onclick="tripPlanner.clearTrip()">
|
||||
hx-post="{% url 'parks:htmx_clear_trip' %}"
|
||||
hx-target="#trip-parks"
|
||||
hx-swap="innerHTML">
|
||||
<i class="mr-1 fas fa-trash"></i>Clear All
|
||||
</button>
|
||||
</div>
|
||||
@@ -193,12 +195,18 @@
|
||||
<div class="mt-4 space-y-2">
|
||||
<button id="optimize-route"
|
||||
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.optimizeRoute()" disabled>
|
||||
hx-post="{% url 'parks:htmx_optimize_route' %}"
|
||||
hx-target="#trip-summary"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#trip-summary-loading">
|
||||
<i class="mr-2 fas fa-route"></i>Optimize Route
|
||||
</button>
|
||||
<button id="calculate-route"
|
||||
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.calculateRoute()" disabled>
|
||||
hx-post="{% url 'parks:htmx_calculate_route' %}"
|
||||
hx-target="#trip-summary"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#trip-summary-loading">
|
||||
<i class="mr-2 fas fa-map"></i>Calculate Route
|
||||
</button>
|
||||
</div>
|
||||
@@ -230,7 +238,10 @@
|
||||
<div class="mt-4">
|
||||
<button id="save-trip"
|
||||
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
onclick="tripPlanner.saveTrip()">
|
||||
hx-post="{% url 'parks:htmx_save_trip' %}"
|
||||
hx-target="#saved-trips"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#trips-loading">
|
||||
<i class="mr-2 fas fa-save"></i>Save Trip
|
||||
</button>
|
||||
</div>
|
||||
@@ -245,12 +256,12 @@
|
||||
<div class="flex gap-2">
|
||||
<button id="fit-route"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.fitRoute()">
|
||||
onclick="(window.roadTripPlanner||{}).fitRoute()">
|
||||
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
||||
</button>
|
||||
<button id="toggle-parks"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.toggleAllParks()">
|
||||
onclick="(window.roadTripPlanner||{}).toggleAllParks()">
|
||||
<i class="mr-1 fas fa-eye"></i>Show All Parks
|
||||
</button>
|
||||
</div>
|
||||
@@ -306,483 +317,12 @@
|
||||
<!-- Sortable JS for drag & drop -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<script src="{% static 'js/roadtrip.js' %}"></script>
|
||||
<script>
|
||||
// Road Trip Planner class
|
||||
class TripPlanner {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.tripParks = [];
|
||||
this.allParks = [];
|
||||
this.parkMarkers = {};
|
||||
this.routeControl = null;
|
||||
this.showingAllParks = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initMap();
|
||||
this.loadAllParks();
|
||||
this.initDragDrop();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
initMap() {
|
||||
// Initialize the map
|
||||
this.map = L.map('map-container', {
|
||||
center: [39.8283, -98.5795],
|
||||
zoom: 4,
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
async loadAllParks() {
|
||||
try {
|
||||
const response = await fetch('{{ map_api_urls.locations }}?types=park&limit=1000');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.data.locations) {
|
||||
this.allParks = data.data.locations;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load parks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initDragDrop() {
|
||||
// Make trip parks sortable
|
||||
new Sortable(document.getElementById('trip-parks'), {
|
||||
animation: 150,
|
||||
ghostClass: 'drag-over',
|
||||
onEnd: (evt) => {
|
||||
this.reorderTripParks(evt.oldIndex, evt.newIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Handle park search results
|
||||
document.addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.target.id === 'park-search-results') {
|
||||
this.handleSearchResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSearchResults() {
|
||||
const results = document.getElementById('park-search-results');
|
||||
if (results.children.length > 0) {
|
||||
results.classList.remove('hidden');
|
||||
} else {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
addParkToTrip(parkData) {
|
||||
// Check if park already in trip
|
||||
if (this.tripParks.find(p => p.id === parkData.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tripParks.push(parkData);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
// Hide search results
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
document.getElementById('park-search').value = '';
|
||||
}
|
||||
|
||||
removeParkFromTrip(parkId) {
|
||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateTripDisplay() {
|
||||
const container = document.getElementById('trip-parks');
|
||||
const emptyState = document.getElementById('empty-trip');
|
||||
|
||||
if (this.tripParks.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
// Clear existing parks (except empty state)
|
||||
Array.from(container.children).forEach(child => {
|
||||
if (child.id !== 'empty-trip') {
|
||||
child.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Add trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const parkElement = this.createTripParkElement(park, index);
|
||||
container.appendChild(parkElement);
|
||||
});
|
||||
}
|
||||
|
||||
createTripParkElement(park, index) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'park-card draggable-item';
|
||||
div.innerHTML = `
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
|
||||
${index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
${park.name}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
${park.formatted_location || 'Location not specified'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
updateTripMarkers() {
|
||||
// Clear existing trip markers
|
||||
Object.values(this.parkMarkers).forEach(marker => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
this.parkMarkers = {};
|
||||
|
||||
// Add markers for trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const marker = this.createTripMarker(park, index);
|
||||
this.parkMarkers[park.id] = marker;
|
||||
marker.addTo(this.map);
|
||||
});
|
||||
|
||||
// Fit map to show all trip parks
|
||||
if (this.tripParks.length > 0) {
|
||||
this.fitRoute();
|
||||
}
|
||||
}
|
||||
|
||||
createTripMarker(park, index) {
|
||||
let markerClass = 'waypoint-stop';
|
||||
if (index === 0) markerClass = 'waypoint-start';
|
||||
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: `waypoint-marker ${markerClass}`,
|
||||
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
|
||||
const marker = L.marker([park.latitude, park.longitude], { icon });
|
||||
|
||||
const popupContent = `
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
|
||||
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
|
||||
Remove from Trip
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
return marker;
|
||||
}
|
||||
|
||||
reorderTripParks(oldIndex, newIndex) {
|
||||
const park = this.tripParks.splice(oldIndex, 1)[0];
|
||||
this.tripParks.splice(newIndex, 0, park);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
|
||||
// Clear route to force recalculation
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
async optimizeRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
try {
|
||||
const parkIds = this.tripParks.map(p => p.id);
|
||||
const response = await fetch('{% url "parks:htmx_optimize_route" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({ park_ids: parkIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.optimized_order) {
|
||||
// Reorder parks based on optimization
|
||||
const optimizedParks = data.optimized_order.map(id =>
|
||||
this.tripParks.find(p => p.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
this.tripParks = optimizedParks;
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Route optimization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
// Remove existing route
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
}
|
||||
|
||||
const waypoints = this.tripParks.map(park =>
|
||||
L.latLng(park.latitude, park.longitude)
|
||||
);
|
||||
|
||||
this.routeControl = L.Routing.control({
|
||||
waypoints: waypoints,
|
||||
routeWhileDragging: false,
|
||||
addWaypoints: false,
|
||||
createMarker: () => null, // Don't create default markers
|
||||
lineOptions: {
|
||||
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
|
||||
}
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeControl.on('routesfound', (e) => {
|
||||
const route = e.routes[0];
|
||||
this.updateTripSummary(route);
|
||||
});
|
||||
}
|
||||
|
||||
updateTripSummary(route) {
|
||||
if (!route) return;
|
||||
|
||||
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
|
||||
const totalTime = this.formatDuration(route.summary.totalTime);
|
||||
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
||||
|
||||
document.getElementById('total-distance').textContent = totalDistance;
|
||||
document.getElementById('total-time').textContent = totalTime;
|
||||
document.getElementById('total-parks').textContent = this.tripParks.length;
|
||||
document.getElementById('total-rides').textContent = totalRides;
|
||||
|
||||
document.getElementById('trip-summary').classList.remove('hidden');
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
fitRoute() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const group = new L.featureGroup(Object.values(this.parkMarkers));
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
toggleAllParks() {
|
||||
// Implementation for showing/hiding all parks on the map
|
||||
const button = document.getElementById('toggle-parks');
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (this.showingAllParks) {
|
||||
// Hide all parks
|
||||
this.showingAllParks = false;
|
||||
icon.className = 'mr-1 fas fa-eye';
|
||||
button.innerHTML = icon.outerHTML + 'Show All Parks';
|
||||
} else {
|
||||
// Show all parks
|
||||
this.showingAllParks = true;
|
||||
icon.className = 'mr-1 fas fa-eye-slash';
|
||||
button.innerHTML = icon.outerHTML + 'Hide All Parks';
|
||||
this.displayAllParks();
|
||||
}
|
||||
}
|
||||
|
||||
displayAllParks() {
|
||||
// Add markers for all parks (implementation depends on requirements)
|
||||
this.allParks.forEach(park => {
|
||||
if (!this.parkMarkers[park.id]) {
|
||||
const marker = L.marker([park.latitude, park.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'location-marker location-marker-park',
|
||||
html: '<div class="location-marker-inner">🎢</div>',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '"')})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
Add to Trip
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.parkMarkers[`all_${park.id}`] = marker;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
const calculateBtn = document.getElementById('calculate-route');
|
||||
|
||||
const hasEnoughParks = this.tripParks.length >= 2;
|
||||
|
||||
optimizeBtn.disabled = !hasEnoughParks;
|
||||
calculateBtn.disabled = !hasEnoughParks;
|
||||
}
|
||||
|
||||
clearTrip() {
|
||||
this.tripParks = [];
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
|
||||
document.getElementById('trip-summary').classList.add('hidden');
|
||||
}
|
||||
|
||||
async saveTrip() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const tripName = prompt('Enter a name for this trip:');
|
||||
if (!tripName) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('{% url "parks:htmx_save_trip" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: tripName,
|
||||
park_ids: this.tripParks.map(p => p.id)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert('Trip saved successfully!');
|
||||
// Refresh saved trips
|
||||
htmx.trigger('#saved-trips', 'refresh');
|
||||
} else {
|
||||
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save trip failed:', error);
|
||||
alert('Failed to save trip');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function for adding parks from search results
|
||||
window.addParkToTrip = function(parkData) {
|
||||
window.tripPlanner.addParkToTrip(parkData);
|
||||
};
|
||||
|
||||
// Initialize trip planner when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.tripPlanner = new TripPlanner();
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
if (globalThis.RoadtripMap) {
|
||||
globalThis.RoadtripMap.init('map-container');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
385
backend/uv.lock
generated
@@ -16,24 +16,24 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.10.0"
|
||||
version = "4.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.9.1"
|
||||
version = "3.9.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/bf/0f3ecda32f1cb3bf1dca480aca08a7a8a3bdc4bed2343a103f30731565c9/asgiref-3.9.2.tar.gz", hash = "sha256:a0249afacb66688ef258ffe503528360443e2b9a8d8c4581b6ebefa58c841ef1", size = 36894, upload-time = "2025-09-23T15:00:55.136Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -95,11 +95,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.1"
|
||||
version = "4.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/58/1546c970afcd2a2428b1bfafecf2371d8951cc34b46701bea73f4280989e/billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f", size = 155031, upload-time = "2024-09-21T13:40:22.491Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/6a/1405343016bce8354b29d90aad6b0bf6485b5e60404516e4b9a3a9646cf0/billiard-4.2.2.tar.gz", hash = "sha256:e815017a062b714958463e07ba15981d802dc53d41c5b69d28c5a7c238f8ecf3", size = 155592, upload-time = "2025-09-20T14:44:40.456Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/da/43b15f28fe5f9e027b41c539abc5469052e9d48fd75f8ff094ba2a0ae767/billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb", size = 86766, upload-time = "2024-09-21T13:40:20.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/80/ef8dff49aae0e4430f81842f7403e14e0ca59db7bbaf7af41245b67c6b25/billiard-4.2.2-py3-none-any.whl", hash = "sha256:4bc05dcf0d1cc6addef470723aac2a6232f3c7ed7475b0b580473a9145829457", size = 86896, upload-time = "2025-09-20T14:44:39.157Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -311,14 +311,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
version = "8.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -378,55 +378,63 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.6"
|
||||
version = "7.10.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351, upload-time = "2025-08-29T15:33:45.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600, upload-time = "2025-08-29T15:33:47.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600, upload-time = "2025-08-29T15:33:48.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206, upload-time = "2025-08-29T15:33:50.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478, upload-time = "2025-08-29T15:33:52.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637, upload-time = "2025-08-29T15:33:53.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529, upload-time = "2025-08-29T15:33:55.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143, upload-time = "2025-08-29T15:33:56.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770, upload-time = "2025-08-29T15:33:58.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566, upload-time = "2025-08-29T15:33:59.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195, upload-time = "2025-08-29T15:34:01.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059, upload-time = "2025-08-29T15:34:02.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287, upload-time = "2025-08-29T15:34:05.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625, upload-time = "2025-08-29T15:34:06.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801, upload-time = "2025-08-29T15:34:08.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027, upload-time = "2025-08-29T15:34:09.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576, upload-time = "2025-08-29T15:34:11.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341, upload-time = "2025-08-29T15:34:13.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468, upload-time = "2025-08-29T15:34:14.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429, upload-time = "2025-08-29T15:34:16.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493, upload-time = "2025-08-29T15:34:17.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757, upload-time = "2025-08-29T15:34:19.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -616,7 +624,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-cloudflareimages-toolkit"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
@@ -624,9 +632,9 @@ dependencies = [
|
||||
{ name = "pillow" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/e0/f19f5f155c8166e0d2e4df18bdcd8cd18ecf64f6d68c4d9d8ace2158514f/django_cloudflareimages_toolkit-1.0.7.tar.gz", hash = "sha256:620d45cb62f9a4dc290e5afe4d3c7e582345d36111bc0770a06d6ce9fc2528d6", size = 136576, upload-time = "2025-08-30T21:26:39.248Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/be/7e31de7680cf66dba19a7764304003d1494877bbd5520b3387cda7411424/django_cloudflareimages_toolkit-1.0.8.tar.gz", hash = "sha256:6c58c58572025b65c41ed8fdb9a3027a47d730e49a92e87c61c7bfed298c5958", size = 137645, upload-time = "2025-09-27T13:05:09.442Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/55/25c9d3af623cc9a635c0083ca922471a24f99d5b4ad7d2f2e554df5bb279/django_cloudflareimages_toolkit-1.0.7-py3-none-any.whl", hash = "sha256:5f0ecf12bfa462c19e5fd8936947ad646130f228ddb8e137f3639feb80085372", size = 44062, upload-time = "2025-08-30T21:26:37.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/bd/37373047dce22c485d730b55ebe4ab7252f4bdc4e7b522097d6711762492/django_cloudflareimages_toolkit-1.0.8-py3-none-any.whl", hash = "sha256:77cb23d7fea3698d2e95882533d31402fac4ffe07360d73832a3d3c0dcfe881d", size = 45644, upload-time = "2025-09-27T13:05:07.675Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -715,15 +723,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-htmx"
|
||||
version = "1.25.0"
|
||||
version = "1.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/9c/47e6db512c0361a53e912ab946c07c7aff874260d7223dc75cf6b1d16b65/django_htmx-1.25.0.tar.gz", hash = "sha256:18ba6977a2636adcbb74ce63d75538be9958d4bdbe18f54dff57fdb6372a7c11", size = 65081, upload-time = "2025-09-18T11:10:24.941Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/ea/3d0fb10e6ebbceab57b5e228c8ae5e4ebed3b6d6767d07468923ca000fa3/django_htmx-1.26.0.tar.gz", hash = "sha256:88ecc2f8a3f13ad5a50e6b16be127f04fba369124cc40a09b21ce33babb04aa6", size = 65345, upload-time = "2025-09-22T09:23:41.582Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/d1/5078af1f88cc1d304fdc667066b5c34e07832c48fea837e466069328d377/django_htmx-1.25.0-py3-none-any.whl", hash = "sha256:b7f8a744b7540dc2ac40b04e0d3eee49b148baba60c467f1e868815d79315289", size = 62022, upload-time = "2025-09-18T11:10:22.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/fc/dbbc54af504427f5f248a73cf9a2d40bfd22631bbf042d313563acc4d223/django_htmx-1.26.0-py3-none-any.whl", hash = "sha256:3a80ffaa6df5a07e833752cd8ada7cee0fa711787841ab17a805075b1aecacc7", size = 62122, upload-time = "2025-09-22T09:23:40.18Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -755,15 +763,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-pghistory"
|
||||
version = "3.8.2"
|
||||
version = "3.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-pgtrigger" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/17/a04ddbbeebc5a6c3df14bbd7a3eb9cc135c6fca733bdbd88d37cf8dc2203/django_pghistory-3.8.2.tar.gz", hash = "sha256:28c2fa37a64c53509b6f2a3ec05e23e852bed06843af67dcac1a652aa9f6c86e", size = 32276, upload-time = "2025-09-13T02:39:48.829Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/49/a9fc978f3c93ef8775b2e9ae0101b92a861565e292b8b58a68652393a387/django_pghistory-3.8.3.tar.gz", hash = "sha256:fd95e070fffa63e815d51a0b75f06cfbb6dd6fea00d6610287574ccdd4410267", size = 32420, upload-time = "2025-09-24T02:09:57.229Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/f3/16027a7ebf6c96bff1a44bfaed55d370faf2f1506e34112e0ecdf018599a/django_pghistory-3.8.2-py3-none-any.whl", hash = "sha256:565bcaa486b4d7b33c98a6313293eda591d1fac1e757f166f110dd91b3c73fb3", size = 39623, upload-time = "2025-09-13T02:39:47.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/67/04f4e92c02843d7269b171f294cb4d0602a5e20372d36503eeb94f6122b7/django_pghistory-3.8.3-py3-none-any.whl", hash = "sha256:00399b05c3040b56b53e4f8662b2ca6c7b63ddd748af43e24920d7922aeacfbf", size = 39805, upload-time = "2025-09-24T02:09:56.348Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -847,17 +855,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-tailwind-cli"
|
||||
version = "4.3.0"
|
||||
version = "4.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-typer" },
|
||||
{ name = "requests" },
|
||||
{ name = "semver" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/14/319efb2f43b635382cee74e80da7ab383c69e98cf6ef35c00a754454931e/django_tailwind_cli-4.3.0.tar.gz", hash = "sha256:20da555409eccaeb3c38837b33186b6523f44cb88c80136de3bac3b593253331", size = 101182, upload-time = "2025-07-12T20:33:02.061Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/09/8359181201a03871e34d8d47685b15244e778c8ece9f209a86d543cb7767/django_tailwind_cli-4.4.2.tar.gz", hash = "sha256:c3ad962710fc95acf1bb45b1b7747fe549d50ff99228cadc4cf2f28fd8d4e8ce", size = 97420, upload-time = "2025-09-23T15:07:23.876Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/1a/1c15852b3002929ed08992aeaaea703c43a43345dc19a09fd457593f52a6/django_tailwind_cli-4.3.0-py3-none-any.whl", hash = "sha256:0ff7d7374a390e63cba77894a13de2bf8721320a5bad97361cb14e160cc824b5", size = 29704, upload-time = "2025-07-12T20:33:00.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/08/8b8c7c4a4f9f4ad3c4815f53c4f98de19b5c37803a9af767d0cebd779af4/django_tailwind_cli-4.4.2-py3-none-any.whl", hash = "sha256:8d1d69ae19209b5d6fd66150d916edbced1d154eee55895d807441dbfe282cae", size = 31688, upload-time = "2025-09-23T15:07:22.16Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -874,7 +881,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-typer"
|
||||
version = "3.3.0"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -882,9 +889,9 @@ dependencies = [
|
||||
{ name = "shellingham" },
|
||||
{ name = "typer-slim" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/e4/e4a12f82f7cd0b0dfec64763c9647472d5cd086c66698d6f051a28fddbee/django_typer-3.3.0.tar.gz", hash = "sha256:7edd5669f2df3e3bc5613386f4f5f8f957567bd8da8907e81e22dc81fffcf7cd", size = 3074870, upload-time = "2025-09-03T04:58:29.328Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/0e/91d8511ead5a1e848c7b7987a660df6063f23971b55372823732c8615b5e/django_typer-3.3.2.tar.gz", hash = "sha256:11ebfb21060414696df23ba556f9646d2fd5b59efbd99df14a1089c5fcdd1f3c", size = 3078697, upload-time = "2025-09-27T18:21:50.468Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/33/3a5a0f50ec114ccbeba4bad1fa1eed951f77b196da78aa8be8a0a10ea98e/django_typer-3.3.0-py3-none-any.whl", hash = "sha256:56fa4a254f0ffe227e7815a1818126f72dcc2aa5cea520bc16c02d9f6f54b394", size = 295776, upload-time = "2025-09-03T04:58:27.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a2/6b65d5efdd1c41f9eef8efb807c92c9170c34d239eaf668cccad9cdf0e9b/django_typer-3.3.2-py3-none-any.whl", hash = "sha256:a3e225bc8fc65f9b1835473b0d8fca98c4adb1b5f12825ac70ba28e2e9f1dd74", size = 295789, upload-time = "2025-09-27T18:21:48.47Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -950,19 +957,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dulwich"
|
||||
version = "0.24.1"
|
||||
version = "0.24.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/f3/13a3425ddf04bd31f1caf3f4fa8de2352700c454cb0536ce3f4dbdc57a81/dulwich-0.24.1.tar.gz", hash = "sha256:e19fd864f10f02bb834bb86167d92dcca1c228451b04458761fc13dabd447758", size = 806136, upload-time = "2025-08-01T10:26:46.887Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/d0/b1275f9609e0d32800daf72786e5bec5602e71dde8db4995d0420d6b5cec/dulwich-0.24.2.tar.gz", hash = "sha256:d474844cf81bf95a6537a80aeec59d714d5d77d8e83d6d37991e2bde54746ca7", size = 883284, upload-time = "2025-09-26T09:11:38.978Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/a5/3f4760169fea1b90df7aea88172699807af6f4f667c878de6a9ee554170f/dulwich-0.24.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a11ec69fc6604228804ddfc32c85b22bc627eca4cf4ff3f27dbe822e6f29477", size = 1080923, upload-time = "2025-08-01T10:26:28.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/d9/7aadd6318aed6f0e1242fa63bd61d80142716d13ea4e307c8b19fc61c9ae/dulwich-0.24.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a9800df7238b586b4c38c00432776781bc889cf02d756dcfb8dc0ecb8fc47a33", size = 1159246, upload-time = "2025-08-01T10:26:29.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/5d/df4256fe009c714e0392817df4fdc1748a901523504f58796d675fce755f/dulwich-0.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3baab4a01aff890e2e6551ccbd33eb2a44173c897f0f027ad3aeab0fb057ec44", size = 1163646, upload-time = "2025-08-01T10:26:31.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/fe/850115d6fa7ad03756e20466ad5b72be54d1b59c1ff7d2b3c13bc4de965f/dulwich-0.24.1-cp313-cp313-win32.whl", hash = "sha256:b39689aa4d143ba1fb0a687a4eb93d2e630d2c8f940aaa6c6911e9c8dca16e6a", size = 762612, upload-time = "2025-08-01T10:26:33.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/7f/f79940e0773efda2ed0e666a0ca0ae7c734fdce4f04b5b60bc5ed268b7cb/dulwich-0.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:8fca9b863b939b52c5f759d292499f0d21a7bf7f8cbb9fdeb8cdd9511c5bc973", size = 779168, upload-time = "2025-08-01T10:26:35.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/bc/a2557d1b0afa5bf1e140f42f8cbca1783e43d7fa17665859c63060957952/dulwich-0.24.1-py3-none-any.whl", hash = "sha256:57cc0dc5a21059698ffa4ed9a7272f1040ec48535193df84b0ee6b16bf615676", size = 440765, upload-time = "2025-08-01T10:26:45.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/6a2ba2df778f39e302d583b4bb7b0898e2d9b8d152138f11754a32cae16b/dulwich-0.24.2-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e237e3724d80be09e8e45f9f9a86342c9c0911ad3fd71fb66f17b20df47c0049", size = 1219066, upload-time = "2025-09-26T09:11:14.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/bb/dd56611414566483e94add8e27974bfb49a340659bf06a03b55cecada74c/dulwich-0.24.2-cp313-cp313-android_21_x86_64.whl", hash = "sha256:8e65c091aacf0d9da167e50f97e203947ae9e439ee8b020915b1decf9ecfdd6d", size = 1219056, upload-time = "2025-09-26T09:11:17.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f5/7ccbf02e1067447418dca45d77ae73a3515c52a54187410107e68c78c494/dulwich-0.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:150b17b30a78c09f105b787ce74ccb094e9a27ada77d25a9df78758b858ee034", size = 1130898, upload-time = "2025-09-26T09:11:19.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/05/8f6909bdd0566b8a5a3faeecc9468efdcd2baa7f17716bdd693e0a13511e/dulwich-0.24.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:540928778a36d468cd98d6d6251af307e9f0743e824fe87189936ca0a88b10fb", size = 1216337, upload-time = "2025-09-26T09:11:20.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/98/844f9777bcec46683c99b7dd35c880af7dc8fa2aecb1ca2caed4e17f9afb/dulwich-0.24.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:fbc6c4ba4b54919d32ba516ea397008e9d1c4d774b87355c5e0fd320d3d86396", size = 1241749, upload-time = "2025-09-26T09:11:22.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/c2/bcb0c89ed9b38c7de1863df5cac036073ca17eb2aa2e38f1df2d6907629b/dulwich-0.24.2-cp313-cp313-win32.whl", hash = "sha256:bc5452099ecb27970c17f8fede63f426509e7668361b9c7b0f495d498bf67c02", size = 816176, upload-time = "2025-09-26T09:11:24.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/b3/26f9b65faf4f368b282e943aaf7f73bacaf584c24c7a6ff48958740e2e40/dulwich-0.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:09c86b889a344667d050a8e0849816ed2f376d33ec9f8be302129a79ca65845b", size = 832605, upload-time = "2025-09-26T09:11:26.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/42/58ce26920e32f17c39541fe0b8dd7b758bbf59063ff9bacb2496201c006b/dulwich-0.24.2-py3-none-any.whl", hash = "sha256:07426d209e870bd869d6eefb03573cf4879e62b30f690c53cc525b602ea413e5", size = 495365, upload-time = "2025-09-26T09:11:37.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1280,30 +1289,54 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1402,11 +1435,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pbs-installer"
|
||||
version = "2025.9.2"
|
||||
version = "2025.9.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/c6/c2ee535a5ad13171fcad702afbce38f421b4432e6d547944d1f526a88ad7/pbs_installer-2025.9.2.tar.gz", hash = "sha256:0da1d59bb5c4d8cfb5aee29ac2a37b37d651a45ab5ede19d1331df9a92464b5d", size = 59187, upload-time = "2025-09-02T22:02:37.903Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/db/077daf042cbbb627275c4345d56fe3f8c2d394673d2b5c510958f84e4b0c/pbs_installer-2025.9.18.tar.gz", hash = "sha256:c0a51a7c1e015723bd8396f02e15b5876e439f74b0f45bbac436b189f903219f", size = 59190, upload-time = "2025-09-19T22:03:12.987Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/2a/1222033f49493bba3f515ab927417ac6ffe237a8144739492009185375c6/pbs_installer-2025.9.2-py3-none-any.whl", hash = "sha256:659a5399278c810761c1e7bc54095f38af11a5b593ce8d45c41a3a9d6759d8f1", size = 60885, upload-time = "2025-09-02T22:02:36.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/fd/a8bab37d8429e2fe2bbda7c1382087d8a8fbe9cf387e4ee5245402aee181/pbs_installer-2025.9.18-py3-none-any.whl", hash = "sha256:8ef55d7675698747505c237015d14c81759bd66a0d4c8b20cec9a2dc96e8434c", size = 60891, upload-time = "2025-09-19T22:03:11.365Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1529,7 +1562,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "poetry"
|
||||
version = "2.2.0"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "build" },
|
||||
@@ -1554,18 +1587,18 @@ dependencies = [
|
||||
{ name = "virtualenv" },
|
||||
{ name = "xattr", marker = "sys_platform == 'darwin'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/a6/28b83bb81911dc5b2a6a2be4006cceb29b0915b2f62d2e44192e315c2456/poetry-2.2.0.tar.gz", hash = "sha256:c6bc7e9d2d5aad4f6818cc5eef1f85fcfb7ee49a1aab3b4ff66d0c6874e74769", size = 3441561, upload-time = "2025-09-14T11:45:31.018Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/28/f790e21769afaaa1f326d9634f9a5c700c4cdb4c468a1707c6db0b350505/poetry-2.2.1.tar.gz", hash = "sha256:bef9aa4bb00ce4c10b28b25e7bac724094802d6958190762c45df6c12749b37c", size = 3441978, upload-time = "2025-09-21T14:51:49.307Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/79/8e1ab13d3c31565a035853b93c75f7f84c38df4fb4ff74fabba2200bc483/poetry-2.2.0-py3-none-any.whl", hash = "sha256:1eb2dde482c0fee65c3b5be85a2cd0ad7c8be05c42041d8555ab89436c433c5f", size = 281550, upload-time = "2025-09-14T11:45:29.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/52/abfc9449312877622aa87ece9e66b2f2d9290661d0023a963aedefda4099/poetry-2.2.1-py3-none-any.whl", hash = "sha256:f5958b908b96c5824e2acbb8b19cdef8a3351c62142d7ecff2d705396c8ca34c", size = 281560, upload-time = "2025-09-21T14:51:46.483Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poetry-core"
|
||||
version = "2.2.0"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/73/8cc4cdc3992d9e03a749dd0ef7438093042a1ed197df8fcfc9dc9502ef0b/poetry_core-2.2.0.tar.gz", hash = "sha256:b4033b71b99717a942030e074fec7e3082e5fde7a8ed10f02cd2413bdf940b1f", size = 378727, upload-time = "2025-09-14T09:39:27.439Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/ef/a16c11de95b638341961765e072dfdd4c9a0be51d6b22d594c5f3255e4bb/poetry_core-2.2.1.tar.gz", hash = "sha256:97e50d8593c8729d3f49364b428583e044087ee3def1e010c6496db76bd65ac5", size = 378867, upload-time = "2025-09-21T14:27:58.99Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/48/6e61a09d02dcd25a4bb1727269c60ad9daa00fef92c643e1ed8cb59a2c84/poetry_core-2.2.0-py3-none-any.whl", hash = "sha256:0edea81d07e88cbd407369eef753c722da8ff1338f554788dc04636e756318fc", size = 338592, upload-time = "2025-09-14T09:39:25.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d8/bb2f602f5e012e177e1c8560125f9770945d36622595990cf1cb794477c2/poetry_core-2.2.1-py3-none-any.whl", hash = "sha256:bdfce710edc10bfcf9ab35041605c480829be4ab23f5bc01202cfe5db8f125ab", size = 338598, upload-time = "2025-09-21T14:27:56.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1898,19 +1931,38 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2090,28 +2142,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.13.1"
|
||||
version = "0.13.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987, upload-time = "2025-09-18T19:52:44.33Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308, upload-time = "2025-09-18T19:51:56.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258, upload-time = "2025-09-18T19:52:00.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554, upload-time = "2025-09-18T19:52:02.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181, upload-time = "2025-09-18T19:52:05.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599, upload-time = "2025-09-18T19:52:07.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178, upload-time = "2025-09-18T19:52:10.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474, upload-time = "2025-09-18T19:52:12.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531, upload-time = "2025-09-18T19:52:15.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267, upload-time = "2025-09-18T19:52:17.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120, upload-time = "2025-09-18T19:52:20.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084, upload-time = "2025-09-18T19:52:23.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105, upload-time = "2025-09-18T19:52:25.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284, upload-time = "2025-09-18T19:52:27.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314, upload-time = "2025-09-18T19:52:30.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360, upload-time = "2025-09-18T19:52:32.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448, upload-time = "2025-09-18T19:52:35.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458, upload-time = "2025-09-18T19:52:38.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893, upload-time = "2025-09-18T19:52:41.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2138,15 +2190,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.38.0"
|
||||
version = "2.39.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/22/60fd703b34d94d216b2387e048ac82de3e86b63bc28869fb076f8bb0204a/sentry_sdk-2.38.0.tar.gz", hash = "sha256:792d2af45e167e2f8a3347143f525b9b6bac6f058fb2014720b40b84ccbeb985", size = 348116, upload-time = "2025-09-15T15:00:37.846Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/72/43294fa4bdd75c51610b5104a3ff834459ba653abb415150aa7826a249dd/sentry_sdk-2.39.0.tar.gz", hash = "sha256:8c185854d111f47f329ab6bc35993f28f7a6b7114db64aa426b326998cfa14e9", size = 348556, upload-time = "2025-09-25T09:15:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/84/bde4c4bbb269b71bc09316af8eb00da91f67814d40337cc12ef9c8742541/sentry_sdk-2.38.0-py2.py3-none-any.whl", hash = "sha256:2324aea8573a3fa1576df7fb4d65c4eb8d9929c8fa5939647397a07179eef8d0", size = 370346, upload-time = "2025-09-15T15:00:35.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/44/4356cc64246ba7b2b920f7c97a85c3c52748e213e250b512ee8152eb559d/sentry_sdk-2.39.0-py2.py3-none-any.whl", hash = "sha256:ba655ca5e57b41569b18e2a5552cb3375209760a5d332cdd87c6c3f28f729602", size = 370851, upload-time = "2025-09-25T09:15:36.35Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2408,24 +2460,24 @@ tls = [
|
||||
|
||||
[[package]]
|
||||
name = "txaio"
|
||||
version = "25.6.1"
|
||||
version = "25.9.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/21/f1d3305ae1d3ca05aa71d509f02f4db11c1357001f7e31f9713e610efc5b/txaio-25.6.1.tar.gz", hash = "sha256:d8c03dca823515c9bca920df33504923ae54f2dabf476cc5a9ed5cc1691ed687", size = 58709, upload-time = "2025-06-26T16:59:29.544Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/20/2e7ccea9ab2dd824d0bd421d9364424afde3bb33863afb80cd9180335019/txaio-25.9.2.tar.gz", hash = "sha256:e42004a077c02eb5819ff004a4989e49db113836708430d59cb13d31bd309099", size = 50008, upload-time = "2025-09-25T22:21:07.958Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/56/aff5af8caa210321d0c206eb19897a4e0b29b4e24c4e24226325950efe0b/txaio-25.6.1-py2.py3-none-any.whl", hash = "sha256:f461b917a14d46077fb1668d0bea4998695d93c9c569cd05fd7f193abdd22414", size = 31250, upload-time = "2025-06-26T16:59:28.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/2c/e276b80f73fc0411cefa1c1eeae6bc17955197a9c3e2b41b41f957322549/txaio-25.9.2-py3-none-any.whl", hash = "sha256:a23ce6e627d130e9b795cbdd46c9eaf8abd35e42d2401bb3fea63d38beda0991", size = 31293, upload-time = "2025-09-25T22:21:06.394Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer-slim"
|
||||
version = "0.17.5"
|
||||
version = "0.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/19/79ce7f1e0ff64f0b011aac73d13a82ddfb2ca236434a1e238424baae3159/typer_slim-0.17.5.tar.gz", hash = "sha256:833a422a7cd7e27e74fcd5d76bdf51b9264c84037daceffb04b152f1a435883a", size = 103779, upload-time = "2025-09-19T18:34:30.098Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/75/d6/489402eda270c00555213bdd53061b23a0ae2b5dccbfe428ebcc9562d883/typer_slim-0.19.2.tar.gz", hash = "sha256:6f601e28fb8249a7507f253e35fb22ccc701403ce99bea6a9923909ddbfcd133", size = 104788, upload-time = "2025-09-23T09:47:42.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/77/9a212a891a3884345612e7f37e81c93a97836a3361681b9c288e80d46c32/typer_slim-0.17.5-py3-none-any.whl", hash = "sha256:fdd3155287586af53170e87a08b6811dea243f700186b9c7d32403d2f4c9c943", size = 46708, upload-time = "2025-09-19T18:34:28.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/19/7aef771b3293e1b7c749eebb2948bb7ccd0e9b56aa222eb4d5e015087730/typer_slim-0.19.2-py3-none-any.whl", hash = "sha256:1c9cdbbcd5b8d30f4118d3cb7c52dc63438b751903fbd980a35df1dfe10c6c91", size = 46806, upload-time = "2025-09-23T09:47:41.385Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2498,11 +2550,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.13"
|
||||
version = "0.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2542,19 +2594,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zope-interface"
|
||||
version = "8.0"
|
||||
version = "8.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/21/a6af230243831459f7238764acb3086a9cf96dbf405d8084d30add1ee2e7/zope_interface-8.0.tar.gz", hash = "sha256:b14d5aac547e635af749ce20bf49a3f5f93b8a854d2a6b1e95d4d5e5dc618f7d", size = 253397, upload-time = "2025-09-12T07:17:13.571Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/3a/7fcf02178b8fad0a51e67e32765cd039ae505d054d744d76b8c2bbcba5ba/zope_interface-8.0.1.tar.gz", hash = "sha256:eba5610d042c3704a48222f7f7c6ab5b243ed26f917e2bc69379456b115e02d1", size = 253746, upload-time = "2025-09-25T05:55:51.285Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7a/1093db3af58fe48299659a7e0bc17cb2be72bf8bf7ea54a429556c816e50/zope_interface-8.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:ee9ecad04269c2da4b1be403a47993981531ffd557064b870eab4094730e5062", size = 208743, upload-time = "2025-09-12T07:24:14.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ec/63003ea86eb37b423ad85575b77b445ca26baa4b15f431d0c2319642ffeb/zope_interface-8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a9a8a71c38628af82a9ea1f7be58e5d19360a38067080c8896f6cbabe167e4f8", size = 208803, upload-time = "2025-09-12T07:24:15.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/0e/e19352096e2933e0047b954d861d74dce34c61283a9c3150aac163a182d9/zope_interface-8.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c0cc51ebd984945362fd3abdc1e140dbd837c3e3b680942b3fa24fe3aac26ef8", size = 258964, upload-time = "2025-09-12T07:58:22.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a5/ec1578b838f364c889746a03960624a8781c9a1cd1b8cc29c57ec8d16df9/zope_interface-8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:07405019f635a93b318807cb2ec7b05a5ef30f67cf913d11eb2f156ddbcead0d", size = 264435, upload-time = "2025-09-12T08:00:30.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/8e/4a8b167481cada8b82b2212eb0003d425a30d1699d3604052e6c66817545/zope_interface-8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:450ab3357799eed6093f3a9f1fa22761b3a9de9ebaf57f416da2c9fb7122cdcb", size = 263942, upload-time = "2025-09-12T08:29:22.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/bd/f9da62983480ecfc5a1147fafbc762bb76e5e8528611c4cf8b9d72b4de13/zope_interface-8.0-cp313-cp313-win_amd64.whl", hash = "sha256:e38bb30a58887d63b80b01115ab5e8be6158b44d00b67197186385ec7efe44c7", size = 212034, upload-time = "2025-09-12T07:22:57.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/dc/3c12fca01c910c793d636ffe9c0984e0646abaf804e44552070228ed0ede/zope_interface-8.0.1-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:c7cc027fc5c61c5d69e5080c30b66382f454f43dc379c463a38e78a9c6bab71a", size = 208992, upload-time = "2025-09-25T05:58:40.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/71/6127b7282a3e380ca927ab2b40778a9c97935a4a57a2656dadc312db5f30/zope_interface-8.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcf9097ff3003b7662299f1c25145e15260ec2a27f9a9e69461a585d79ca8552", size = 209051, upload-time = "2025-09-25T05:58:42.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/86/4387a9f951ee18b0e41fda77da77d59c33e59f04660578e2bad688703e64/zope_interface-8.0.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6d965347dd1fb9e9a53aa852d4ded46b41ca670d517fd54e733a6b6a4d0561c2", size = 259223, upload-time = "2025-09-25T05:58:23.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/08/ce60a114466abc067c68ed41e2550c655f551468ae17b4b17ea360090146/zope_interface-8.0.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a3b8bb77a4b89427a87d1e9eb969ab05e38e6b4a338a9de10f6df23c33ec3c2", size = 264690, upload-time = "2025-09-25T05:58:15.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/9a/62a9ba3a919594605a07c34eee3068659bbd648e2fa0c4a86d876810b674/zope_interface-8.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:87e6b089002c43231fb9afec89268391bcc7a3b66e76e269ffde19a8112fb8d5", size = 264201, upload-time = "2025-09-25T06:26:27.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/06/8fe88bd7edef60566d21ef5caca1034e10f6b87441ea85de4bbf9ea74768/zope_interface-8.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:64a43f5280aa770cbafd0307cb3d1ff430e2a1001774e8ceb40787abe4bb6658", size = 212273, upload-time = "2025-09-25T06:00:25.398Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -5,8 +5,8 @@ This script demonstrates real-world scenarios for using the OSM Road Trip Servic
|
||||
in the ThrillWiki application.
|
||||
"""
|
||||
|
||||
from parks.models import Park
|
||||
from parks.services import RoadTripService
|
||||
from apps.parks.models import Park
|
||||
from apps.parks.services import RoadTripService
|
||||
import os
|
||||
import django
|
||||
|
||||
@@ -14,6 +14,41 @@ import django
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings")
|
||||
django.setup()
|
||||
|
||||
# New small helpers and constant to simplify functions and avoid repeated literals
|
||||
MAGIC_KINGDOM = "Magic Kingdom"
|
||||
|
||||
|
||||
def _format_coords(coords):
|
||||
"""
|
||||
Return (lat, lon) tuple or None for a coords object/sequence.
|
||||
Accepts objects with .latitude/.longitude or indexable (lat, lon).
|
||||
"""
|
||||
if not coords:
|
||||
return None
|
||||
lat = getattr(coords, "latitude", None)
|
||||
lon = getattr(coords, "longitude", None)
|
||||
if lat is not None and lon is not None:
|
||||
return (lat, lon)
|
||||
# Fallback to indexable
|
||||
try:
|
||||
return (coords[0], coords[1])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _print_route_summary(route, indent=" "):
|
||||
"""Safely print route summary fields if route is present."""
|
||||
if not route:
|
||||
return
|
||||
# Use attributes with fallback to dict keys if needed
|
||||
formatted_distance = getattr(route, "formatted_distance", None) or route.get(
|
||||
"formatted_distance", "N/A"
|
||||
) if isinstance(route, dict) else getattr(route, "formatted_distance", "N/A")
|
||||
formatted_duration = getattr(route, "formatted_duration", None) or route.get(
|
||||
"formatted_duration", "N/A"
|
||||
) if isinstance(route, dict) else getattr(route, "formatted_duration", "N/A")
|
||||
print(f"{indent}{formatted_distance}, {formatted_duration}")
|
||||
|
||||
|
||||
def demo_florida_theme_park_trip():
|
||||
"""
|
||||
@@ -26,11 +61,8 @@ def demo_florida_theme_park_trip():
|
||||
|
||||
# Define Florida theme parks with addresses
|
||||
florida_parks = [
|
||||
("Magic Kingdom", "Magic Kingdom Dr, Orlando, FL 32830"),
|
||||
(
|
||||
"Universal Studios Florida",
|
||||
"6000 Universal Blvd, Orlando, FL 32819",
|
||||
),
|
||||
(MAGIC_KINGDOM, "Magic Kingdom Dr, Orlando, FL 32830"),
|
||||
("Universal Studios Florida", "6000 Universal Blvd, Orlando, FL 32819"),
|
||||
("SeaWorld Orlando", "7007 Sea World Dr, Orlando, FL 32821"),
|
||||
("Busch Gardens Tampa", "10165 McKinley Dr, Tampa, FL 33612"),
|
||||
]
|
||||
@@ -38,18 +70,21 @@ def demo_florida_theme_park_trip():
|
||||
print("Planning trip for these Florida parks:")
|
||||
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}...")
|
||||
coords = service.geocode_address(address)
|
||||
if coords:
|
||||
latlon = _format_coords(coords)
|
||||
if latlon:
|
||||
park_coords[name] = coords
|
||||
print(
|
||||
f" ✅ Located at {
|
||||
coords.latitude:.4f}, {
|
||||
coords.longitude:.4f}"
|
||||
)
|
||||
else:
|
||||
print(f" ✅ Located at {latlon[0]:.4f}, {latlon[1]:.4f}")
|
||||
return True
|
||||
print(f" ❌ Could not geocode {address}")
|
||||
return False
|
||||
|
||||
for name, address in florida_parks:
|
||||
_geocode_and_store(name, address)
|
||||
|
||||
if len(park_coords) < 2:
|
||||
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])
|
||||
if route:
|
||||
print(f" {park1} ↔ {park2}")
|
||||
print(
|
||||
f" {
|
||||
route.formatted_distance}, {
|
||||
route.formatted_duration}"
|
||||
)
|
||||
_print_route_summary(route, indent=" ")
|
||||
|
||||
# Find central park for radiating searches
|
||||
print("\n🎢 Parks within 100km of Magic Kingdom:")
|
||||
magic_kingdom_coords = park_coords.get("Magic Kingdom")
|
||||
print(f"\n🎢 Parks within 100km of {MAGIC_KINGDOM}:")
|
||||
magic_kingdom_coords = park_coords.get(MAGIC_KINGDOM)
|
||||
if magic_kingdom_coords:
|
||||
for name, coords in park_coords.items():
|
||||
if name != "Magic Kingdom":
|
||||
if name != MAGIC_KINGDOM:
|
||||
route = service.calculate_route(magic_kingdom_coords, coords)
|
||||
if route:
|
||||
print(
|
||||
f" {name}: {
|
||||
route.formatted_distance} ({
|
||||
route.formatted_duration})"
|
||||
)
|
||||
_print_route_summary(route, indent=f" {name}: ")
|
||||
|
||||
|
||||
def demo_cross_country_road_trip():
|
||||
@@ -99,10 +126,7 @@ def demo_cross_country_road_trip():
|
||||
major_parks = [
|
||||
("Disneyland", "1313 Disneyland Dr, Anaheim, CA 92802"),
|
||||
("Cedar Point", "1 Cedar Point Dr, Sandusky, OH 44870"),
|
||||
(
|
||||
"Six Flags Magic Mountain",
|
||||
"26101 Magic Mountain Pkwy, Valencia, CA 91355",
|
||||
),
|
||||
("Six Flags Magic Mountain", "26101 Magic Mountain Pkwy, Valencia, CA 91355"),
|
||||
("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)
|
||||
if 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:
|
||||
# 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]
|
||||
|
||||
if from_park in park_coords and to_park in park_coords:
|
||||
route = service.calculate_route(
|
||||
park_coords[from_park], park_coords[to_park]
|
||||
)
|
||||
route = service.calculate_route(park_coords[from_park], park_coords[to_park])
|
||||
if route:
|
||||
total_distance += route.distance_km
|
||||
total_time += route.duration_minutes
|
||||
total_distance += getattr(route, "distance_km", 0) or route.get("distance_km", 0) if isinstance(route, dict) else getattr(route, "distance_km", 0)
|
||||
total_time += getattr(route, "duration_minutes", 0) or route.get("duration_minutes", 0) if isinstance(route, dict) else getattr(route, "duration_minutes", 0)
|
||||
print(f" {i + 1}. {from_park} → {to_park}")
|
||||
print(
|
||||
f" {
|
||||
route.formatted_distance}, {
|
||||
route.formatted_duration}"
|
||||
)
|
||||
_print_route_summary(route, indent=" ")
|
||||
|
||||
print("\n📊 Trip Summary:")
|
||||
print(f" Total Distance: {total_distance:.1f}km")
|
||||
print(
|
||||
f" Total Driving Time: {
|
||||
total_time //
|
||||
60}h {
|
||||
total_time %
|
||||
60}min"
|
||||
)
|
||||
print(f" Average Distance per Leg: {total_distance / 3:.1f}km")
|
||||
hours = total_time // 60
|
||||
mins = total_time % 60
|
||||
print(f" Total Driving Time: {hours}h {mins}min")
|
||||
# avoid division by zero
|
||||
legs = max(1, len(route_order) - 1)
|
||||
print(f" Average Distance per Leg: {total_distance / legs:.1f}km")
|
||||
|
||||
|
||||
def demo_database_integration():
|
||||
@@ -171,9 +189,7 @@ def demo_database_integration():
|
||||
service = RoadTripService()
|
||||
|
||||
# Get parks that have location data
|
||||
parks_with_location = Park.objects.filter(
|
||||
location__point__isnull=False
|
||||
).select_related("location")[:5]
|
||||
parks_with_location = Park.objects.filter(location__point__isnull=False).select_related("location")[:5]
|
||||
|
||||
if not parks_with_location:
|
||||
print("❌ No parks with location data found in database")
|
||||
@@ -182,9 +198,10 @@ def demo_database_integration():
|
||||
print(f"Found {len(parks_with_location)} parks with location data:")
|
||||
|
||||
for park in parks_with_location:
|
||||
coords = park.coordinates
|
||||
if coords:
|
||||
print(f" 🎢 {park.name}: {coords[0]:.4f}, {coords[1]:.4f}")
|
||||
coords = getattr(park, "coordinates", None)
|
||||
latlon = _format_coords(coords)
|
||||
if latlon:
|
||||
print(f" 🎢 {park.name}: {latlon[0]:.4f}, {latlon[1]:.4f}")
|
||||
|
||||
# Demonstrate nearby park search
|
||||
if len(parks_with_location) >= 1:
|
||||
@@ -196,13 +213,12 @@ def demo_database_integration():
|
||||
if nearby_parks:
|
||||
print(f" Found {len(nearby_parks)} nearby parks:")
|
||||
for result in nearby_parks[:3]: # Show top 3
|
||||
park = result["park"]
|
||||
print(
|
||||
f" 📍 {
|
||||
park.name}: {
|
||||
result['formatted_distance']} ({
|
||||
result['formatted_duration']})"
|
||||
)
|
||||
park = result.get("park") if isinstance(result, dict) else getattr(result, "park", None)
|
||||
# use safe formatted strings
|
||||
formatted_distance = result.get("formatted_distance", "N/A") if isinstance(result, dict) else getattr(result, "formatted_distance", "N/A")
|
||||
formatted_duration = result.get("formatted_duration", "N/A") if isinstance(result, dict) else getattr(result, "formatted_duration", "N/A")
|
||||
if park:
|
||||
print(f" 📍 {park.name}: {formatted_distance} ({formatted_duration})")
|
||||
else:
|
||||
print(" No nearby parks found (may need larger radius)")
|
||||
|
||||
@@ -218,17 +234,17 @@ def demo_database_integration():
|
||||
|
||||
if trip:
|
||||
print("\n✅ Optimized Route:")
|
||||
print(f" Total Distance: {trip.formatted_total_distance}")
|
||||
print(f" Total Duration: {trip.formatted_total_duration}")
|
||||
print(f" Total Distance: {getattr(trip, 'formatted_total_distance', 'N/A')}")
|
||||
print(f" Total Duration: {getattr(trip, 'formatted_total_duration', 'N/A')}")
|
||||
print(" Route:")
|
||||
|
||||
for i, leg in enumerate(trip.legs, 1):
|
||||
print(f" {i}. {leg.from_park.name} → {leg.to_park.name}")
|
||||
print(
|
||||
f" {
|
||||
leg.route.formatted_distance}, {
|
||||
leg.route.formatted_duration}"
|
||||
)
|
||||
for i, leg in enumerate(getattr(trip, "legs", []) or [], 1):
|
||||
from_park = getattr(leg, "from_park", None)
|
||||
to_park = getattr(leg, "to_park", None)
|
||||
route = getattr(leg, "route", None)
|
||||
if from_park and to_park:
|
||||
print(f" {i}. {from_park.name} → {to_park.name}")
|
||||
_print_route_summary(route, indent=" ")
|
||||
else:
|
||||
print(" ❌ Could not optimize trip route")
|
||||
|
||||
@@ -243,9 +259,7 @@ def demo_geocoding_fallback():
|
||||
service = RoadTripService()
|
||||
|
||||
# Get parks without location data
|
||||
parks_without_coords = Park.objects.filter(
|
||||
location__point__isnull=True
|
||||
).select_related("location")[:3]
|
||||
parks_without_coords = Park.objects.filter(location__point__isnull=True).select_related("location")[:3]
|
||||
|
||||
if not parks_without_coords:
|
||||
print("✅ All parks already have coordinates")
|
||||
@@ -256,14 +270,15 @@ def demo_geocoding_fallback():
|
||||
for park in parks_without_coords:
|
||||
print(f"\n🎢 {park.name}")
|
||||
|
||||
if hasattr(park, "location") and park.location:
|
||||
location = park.location
|
||||
location = getattr(park, "location", None)
|
||||
if location:
|
||||
# use getattr to avoid attribute errors
|
||||
address_parts = [
|
||||
park.name,
|
||||
location.street_address,
|
||||
location.city,
|
||||
location.state,
|
||||
location.country,
|
||||
getattr(park, "name", None),
|
||||
getattr(location, "street_address", None),
|
||||
getattr(location, "city", None),
|
||||
getattr(location, "state", None),
|
||||
getattr(location, "country", None),
|
||||
]
|
||||
address = ", ".join(part for part in address_parts if part)
|
||||
print(f" Address: {address}")
|
||||
@@ -271,8 +286,12 @@ def demo_geocoding_fallback():
|
||||
# Try to geocode
|
||||
success = service.geocode_park_if_needed(park)
|
||||
if success:
|
||||
coords = park.coordinates
|
||||
print(f" ✅ Geocoded to: {coords[0]:.4f}, {coords[1]:.4f}")
|
||||
coords = getattr(park, "coordinates", None)
|
||||
latlon = _format_coords(coords)
|
||||
if latlon:
|
||||
print(f" ✅ Geocoded to: {latlon[0]:.4f}, {latlon[1]:.4f}")
|
||||
else:
|
||||
print(" ✅ Geocoded but coordinates unavailable")
|
||||
else:
|
||||
print(" ❌ Geocoding failed")
|
||||
else:
|
||||
@@ -302,7 +321,11 @@ def demo_cache_performance():
|
||||
first_duration = time.time() - start_time
|
||||
|
||||
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")
|
||||
|
||||
# Second request (cache hit)
|
||||
@@ -312,17 +335,22 @@ def demo_cache_performance():
|
||||
second_duration = time.time() - start_time
|
||||
|
||||
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")
|
||||
|
||||
if first_duration > second_duration:
|
||||
if first_duration > second_duration and second_duration > 0:
|
||||
speedup = first_duration / second_duration
|
||||
print(f" 🚀 Cache speedup: {speedup:.1f}x faster")
|
||||
|
||||
if (
|
||||
coords1.latitude == coords2.latitude
|
||||
and coords1.longitude == coords2.longitude
|
||||
):
|
||||
# Compare coordinates if both present
|
||||
if coords1 and coords2:
|
||||
latlon1 = _format_coords(coords1)
|
||||
latlon2 = _format_coords(coords2)
|
||||
if latlon1 and latlon2 and latlon1 == latlon2:
|
||||
print(" ✅ Results identical (cache working)")
|
||||
|
||||
|
||||
|
||||
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
@@ -3,6 +3,7 @@ name = "thrillwiki"
|
||||
version = "0.1.0"
|
||||
description = "A Django + React application using reactivated.io"
|
||||
authors = ["Your Name <your.email@example.com>"]
|
||||
packages = [{include = "thrillwiki", from = "backend"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
@@ -10,7 +11,7 @@ Django = "^5.0"
|
||||
djangorestframework = "^3.14.0"
|
||||
django-cors-headers = "^4.3.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^25.1.0"
|
||||
isort = "^6.0.0"
|
||||
mypy = "^1.8.0"
|
||||
@@ -26,6 +27,7 @@ django_settings = "thrillwiki.settings"
|
||||
[project]
|
||||
name = "thrillwiki"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"Django>=5.0",
|
||||
"djangorestframework>=3.14.0",
|
||||
|
||||
@@ -45,32 +45,37 @@ class Command(BaseCommand):
|
||||
]
|
||||
|
||||
if files:
|
||||
# Get the first file and update the database
|
||||
# record
|
||||
file_path = os.path.join(
|
||||
content_type, identifier, files[0]
|
||||
)
|
||||
# Get the first file and update the database record
|
||||
file_path = os.path.join(content_type, identifier, files[0])
|
||||
if os.path.exists(os.path.join("media", file_path)):
|
||||
photo.image.name = file_path
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"Updated path for photo {
|
||||
photo.id} to {file_path}"
|
||||
f"Updated path for photo {photo.id} to {file_path}"
|
||||
)
|
||||
else:
|
||||
# If the expected file is still missing, fall back to placeholder
|
||||
placeholder = os.path.join("placeholders", "default.svg")
|
||||
photo.image.name = placeholder
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"File not found for photo {
|
||||
photo.id}: {file_path}"
|
||||
f"File missing for photo {photo.id}; set placeholder {placeholder}"
|
||||
)
|
||||
else:
|
||||
# No files found for this identifier -> set placeholder
|
||||
placeholder = os.path.join("placeholders", "default.svg")
|
||||
photo.image.name = placeholder
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"No files found in directory for photo {
|
||||
photo.id}: {media_dir}"
|
||||
f"No files in {media_dir} for photo {photo.id}; set placeholder {placeholder}"
|
||||
)
|
||||
else:
|
||||
# Directory missing -> set placeholder
|
||||
placeholder = os.path.join("placeholders", "default.svg")
|
||||
photo.image.name = placeholder
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"Directory not found for photo {
|
||||
photo.id}: {media_dir}"
|
||||
f"Directory not found for photo {photo.id}: {media_dir}; set placeholder {placeholder}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -49,9 +49,17 @@ class Command(BaseCommand):
|
||||
if files:
|
||||
current_path = os.path.join(old_dir, files[0])
|
||||
|
||||
# Skip if file still not found
|
||||
# If file still not found, set placeholder and continue
|
||||
if not os.path.exists(current_path):
|
||||
self.stdout.write(f"Skipping {current_name} - file not found")
|
||||
placeholder = os.path.join("placeholders", "default.svg")
|
||||
try:
|
||||
photo.image.name = placeholder
|
||||
photo.save()
|
||||
self.stdout.write(
|
||||
f"File for {current_name} not found; set placeholder {placeholder} for photo {photo.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(f"Error setting placeholder for photo {photo.id}: {e}")
|
||||
continue
|
||||
|
||||
# Get content type and object
|
||||
|
||||
|
Before Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |