mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 05:51:08 -05:00
feat: Enhance parks listing with view mode toggle and search functionality
- Implemented a consolidated search bar for parks with live search capabilities. - Added view mode toggle between grid and list views for better user experience. - Updated park listing template to support dynamic rendering based on selected view mode. - Improved pagination controls with HTMX for seamless navigation. - Fixed import paths in parks and rides API to resolve 501 errors, ensuring proper functionality. - Documented changes and integration requirements for frontend compatibility.
This commit is contained in:
@@ -26,8 +26,7 @@ from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
# Import models
|
||||
try:
|
||||
from apps.parks.models import Park
|
||||
from apps.companies.models import Company
|
||||
from apps.parks.models import Park, Company
|
||||
MODELS_AVAILABLE = True
|
||||
except Exception:
|
||||
Park = None # type: ignore
|
||||
@@ -165,9 +164,10 @@ class ParkListCreateAPIView(APIView):
|
||||
qs = Park.objects.all().select_related(
|
||||
"operator", "property_owner", "location"
|
||||
).prefetch_related("rides").annotate(
|
||||
ride_count=Count('rides'),
|
||||
roller_coaster_count=Count('rides', filter=Q(rides__category='RC')),
|
||||
average_rating=Avg('reviews__rating')
|
||||
ride_count_calculated=Count('rides'),
|
||||
roller_coaster_count_calculated=Count(
|
||||
'rides', filter=Q(rides__category='RC')),
|
||||
average_rating_calculated=Avg('reviews__rating')
|
||||
)
|
||||
|
||||
# Apply comprehensive filtering
|
||||
|
||||
@@ -36,16 +36,15 @@ from apps.api.v1.serializers.rides import (
|
||||
|
||||
# Attempt to import model-level helpers; fall back gracefully if not present.
|
||||
try:
|
||||
from apps.rides.models import Ride, RideModel, Company as RideCompany # type: ignore
|
||||
from apps.parks.models import Park, Company as ParkCompany # type: ignore
|
||||
from apps.rides.models import Ride, RideModel
|
||||
from apps.parks.models import Park, Company
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except Exception:
|
||||
Ride = None # type: ignore
|
||||
RideModel = None # type: ignore
|
||||
RideCompany = None # type: ignore
|
||||
Company = None # type: ignore
|
||||
Park = None # type: ignore
|
||||
ParkCompany = None # type: ignore
|
||||
MODELS_AVAILABLE = False
|
||||
|
||||
# Attempt to import ModelChoices to return filter options
|
||||
@@ -630,10 +629,10 @@ class RideDetailAPIView(APIView):
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
|
||||
validated_data = serializer_in.validated_data
|
||||
park_change_info = None
|
||||
|
||||
|
||||
# Handle park change specially if park_id is being updated
|
||||
if 'park_id' in validated_data:
|
||||
new_park_id = validated_data.pop('park_id')
|
||||
@@ -644,21 +643,21 @@ class RideDetailAPIView(APIView):
|
||||
park_change_info = ride.move_to_park(new_park)
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Target park not found")
|
||||
|
||||
|
||||
# Apply other field updates
|
||||
for key, value in validated_data.items():
|
||||
setattr(ride, key, value)
|
||||
|
||||
|
||||
ride.save()
|
||||
|
||||
|
||||
# Prepare response data
|
||||
serializer = RideDetailOutputSerializer(ride, context={"request": request})
|
||||
response_data = serializer.data
|
||||
|
||||
|
||||
# Add park change information to response if applicable
|
||||
if park_change_info:
|
||||
response_data['park_change_info'] = park_change_info
|
||||
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
def put(self, request: Request, pk: int) -> Response:
|
||||
@@ -894,7 +893,7 @@ class CompanySearchAPIView(APIView):
|
||||
if not q:
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
if RideCompany is None:
|
||||
if Company is None:
|
||||
# Provide helpful placeholder structure
|
||||
return Response(
|
||||
[
|
||||
@@ -903,7 +902,7 @@ class CompanySearchAPIView(APIView):
|
||||
]
|
||||
)
|
||||
|
||||
qs = RideCompany.objects.filter(name__icontains=q)[:20] # type: ignore
|
||||
qs = Company.objects.filter(name__icontains=q)[:20] # type: ignore
|
||||
results = [
|
||||
{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs
|
||||
]
|
||||
|
||||
@@ -235,9 +235,9 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
||||
self.filter_service = ParkFilterService()
|
||||
|
||||
def get_template_names(self) -> list[str]:
|
||||
"""Return park_list_item.html for HTMX requests"""
|
||||
"""Return park_list.html for HTMX requests"""
|
||||
if self.request.htmx:
|
||||
return ["parks/partials/park_list_item.html"]
|
||||
return ["parks/partials/park_list.html"]
|
||||
return [self.template_name]
|
||||
|
||||
def get_view_mode(self) -> ViewMode:
|
||||
@@ -514,7 +514,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
if result['status'] == 'auto_approved':
|
||||
# Moderator submission was auto-approved
|
||||
self.object = result['created_object']
|
||||
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
@@ -555,7 +555,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
f"Added {uploaded_count} photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
|
||||
elif result['status'] == 'queued':
|
||||
# Regular user submission was queued
|
||||
messages.success(
|
||||
@@ -565,7 +565,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
)
|
||||
# Redirect to parks list since we don't have an object yet
|
||||
return HttpResponseRedirect(reverse("parks:park_list"))
|
||||
|
||||
|
||||
elif result['status'] == 'failed':
|
||||
# Auto-approval failed
|
||||
messages.error(
|
||||
@@ -573,7 +573,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
f"Error creating park: {result['message']}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
# Fallback error case
|
||||
messages.error(
|
||||
self.request,
|
||||
@@ -727,7 +727,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
f"Added {uploaded_count} new photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
|
||||
elif result['status'] == 'queued':
|
||||
# Regular user submission was queued
|
||||
messages.success(
|
||||
@@ -738,7 +738,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
return HttpResponseRedirect(
|
||||
reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
)
|
||||
|
||||
|
||||
elif result['status'] == 'failed':
|
||||
# Auto-approval failed
|
||||
messages.error(
|
||||
@@ -746,7 +746,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
f"Error updating park: {result['message']}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
# Fallback error case
|
||||
messages.error(
|
||||
self.request,
|
||||
|
||||
12
backend/static/js/alpine.min.js
vendored
12
backend/static/js/alpine.min.js
vendored
File diff suppressed because one or more lines are too long
5
backend/static/js/cdn.min.js
vendored
5
backend/static/js/cdn.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,100 +11,225 @@
|
||||
<!-- Results Section -->
|
||||
<div id="search-results">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<!-- Consolidated Search and View Controls -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Search Results
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ results.count|default:0 }} found)</span>
|
||||
</h2>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<!-- Left side: Search bar with results count -->
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
name="search"
|
||||
value="{{ request.GET.search|default:'' }}"
|
||||
placeholder="Search parks by name, location..."
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="input changed delay:500ms"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="w-full pl-10 pr-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Parks ({{ results.count|default:0 }} found)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: View switching buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1" x-data="{ viewMode: '{{ request.GET.view_mode|default:'grid' }}' }">
|
||||
<button type="button"
|
||||
@click="viewMode = 'grid'; switchView('grid')"
|
||||
:class="viewMode === 'grid' ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
class="flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="viewMode = 'list'; switchView('list')"
|
||||
:class="viewMode === 'list' ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
class="flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for park in results %}
|
||||
<div class="p-6 flex flex-col md:flex-row gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<!-- Park Image -->
|
||||
<div class="md:w-48 h-32 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<!-- Results Container -->
|
||||
<div id="results-container" class="divide-y divide-gray-200 dark:divide-gray-700" x-data="{ viewMode: '{{ request.GET.view_mode|default:'grid' }}' }">
|
||||
<!-- Grid View -->
|
||||
<div x-show="viewMode === 'grid'" class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for park in results %}
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-shadow">
|
||||
<!-- Park Image -->
|
||||
<div class="h-48 bg-gray-200 dark:bg-gray-600 overflow-hidden">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Park Details -->
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if park.formatted_location %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 flex items-center mb-3">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{{ park.formatted_location }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{% if park.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
{{ park.average_rating }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} dark:bg-opacity-30">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
{{ park.ride_count }} Ride{{ park.ride_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{{ park.description|truncatewords:20 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<div class="col-span-full p-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No parks found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try adjusting your search criteria or filters.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Park Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if park.formatted_location %}
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400 flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{{ park.formatted_location }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{% if park.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
<!-- List View -->
|
||||
<div x-show="viewMode === 'list'" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for park in results %}
|
||||
<div class="p-6 flex flex-col md:flex-row gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<!-- Park Image -->
|
||||
<div class="md:w-48 h-32 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{{ park.average_rating }}/10
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Park Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if park.formatted_location %}
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400 flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{{ park.formatted_location }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{% if park.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
{{ park.average_rating }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} dark:bg-opacity-30">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
{{ park.ride_count }} Ride{{ park.ride_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
|
||||
{{ park.coaster_count }} Coaster{{ park.coaster_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{{ park.description|truncatewords:30 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} dark:bg-opacity-30">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
{{ park.ride_count }} Ride{{ park.ride_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
|
||||
{{ park.coaster_count }} Coaster{{ park.coaster_count|pluralize }}
|
||||
</span>
|
||||
{% if park.opening_date %}
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
Opened: {{ park.opening_date|date:"Y" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{{ park.description|truncatewords:30 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if park.opening_date %}
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
Opened: {{ park.opening_date|date:"Y" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No parks found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try adjusting your search criteria or filters.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No parks found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try adjusting your search criteria or filters.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,4 +239,62 @@
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://unpkg.com/unpoly@3/unpoly.min.js"></script>
|
||||
|
||||
<script>
|
||||
// View switching functionality
|
||||
function switchView(mode) {
|
||||
// Update URL parameter
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('view_mode', mode);
|
||||
|
||||
// Update the URL without reloading
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
// Store preference in localStorage
|
||||
localStorage.setItem('parkViewMode', mode);
|
||||
}
|
||||
|
||||
// Initialize view mode from URL or localStorage
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlViewMode = urlParams.get('view_mode');
|
||||
const savedViewMode = localStorage.getItem('parkViewMode');
|
||||
const defaultViewMode = urlViewMode || savedViewMode || 'grid';
|
||||
|
||||
// Set initial view mode
|
||||
if (!urlViewMode) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('view_mode', defaultViewMode);
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced search functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
|
||||
// Preserve view mode in search requests
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
// Get current view mode
|
||||
const currentViewMode = new URLSearchParams(window.location.search).get('view_mode') || 'grid';
|
||||
|
||||
// Add view mode to the HTMX request
|
||||
const currentUrl = new URL(e.target.getAttribute('hx-get'), window.location.origin);
|
||||
currentUrl.searchParams.set('view_mode', currentViewMode);
|
||||
currentUrl.searchParams.set('search', e.target.value);
|
||||
|
||||
// Update the hx-get attribute
|
||||
e.target.setAttribute('hx-get', currentUrl.pathname + currentUrl.search);
|
||||
|
||||
// Trigger the HTMX request
|
||||
htmx.trigger(e.target, 'input');
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
93
backend/templates/parks/park_list.html
Normal file
93
backend/templates/parks/park_list.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Parks{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Consolidated Search and View Controls Bar -->
|
||||
<div class="bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<!-- Search Section -->
|
||||
<div class="flex-1 max-w-2xl">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value="{{ search_query }}"
|
||||
placeholder="Search parks by name, location, or features..."
|
||||
class="block w-full pl-10 pr-3 py-2 border border-gray-600 rounded-md leading-5 bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='view_mode']"
|
||||
hx-indicator="#search-spinner"
|
||||
/>
|
||||
<div id="search-spinner" class="absolute inset-y-0 right-0 pr-3 flex items-center htmx-indicator">
|
||||
<svg class="animate-spin h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Count and View Controls -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Results Count -->
|
||||
<div class="text-gray-300 text-sm whitespace-nowrap">
|
||||
<span class="font-medium">Parks</span>
|
||||
{% if total_results %}
|
||||
<span class="text-gray-400">({{ total_results }} found)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="flex bg-gray-700 rounded-lg p-1">
|
||||
<input type="hidden" name="view_mode" value="{{ view_mode }}" />
|
||||
|
||||
<!-- Grid View Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'grid' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
|
||||
title="Grid View"
|
||||
hx-get="{% url 'parks:park_list' %}?view_mode=grid"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='search']"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- List View Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'list' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
|
||||
title="List View"
|
||||
hx-get="{% url 'parks:park_list' %}?view_mode=list"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='search']"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="park-results">
|
||||
{% include "parks/partials/park_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,12 +1,74 @@
|
||||
<!-- Parks Grid -->
|
||||
<div class="grid-adaptive">
|
||||
{% if view_mode == 'list' %}
|
||||
<!-- Parks List View -->
|
||||
<div class="space-y-4">
|
||||
{% for park in parks %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 overflow-hidden">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
{% if park.photos.exists %}
|
||||
<div class="md:w-48 md:flex-shrink-0">
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-48 md:h-full object-cover">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-1 p-6">
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
{% if park.city or park.state or park.country %}
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-3">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
{% spaceless %}
|
||||
{% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %}
|
||||
{% endspaceless %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if park.operator %}
|
||||
<p class="text-blue-600 dark:text-blue-400 mb-3">
|
||||
{{ park.operator.name }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-col items-start md:items-end gap-2 mt-4 md:mt-0">
|
||||
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.average_rating %}
|
||||
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||
{{ park.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="py-8 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Parks Grid View -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for park in parks %}
|
||||
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
{% if park.photos.exists %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="object-cover w-full">
|
||||
class="object-cover w-full h-48">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
@@ -21,7 +83,6 @@
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
{% spaceless %}
|
||||
{% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %}
|
||||
</p>
|
||||
{% endspaceless %}
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -48,19 +109,28 @@
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-3 py-8 text-center">
|
||||
<div class="col-span-full py-8 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="inline-flex rounded-md shadow-xs">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">« First</a>
|
||||
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
|
||||
<a href="?page=1&{{ request.GET.urlencode }}"
|
||||
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
hx-get="?page=1&{{ request.GET.urlencode }}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true">« First</a>
|
||||
<a href="?page={{ page_obj.previous_page_number }}&{{ request.GET.urlencode }}"
|
||||
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
hx-get="?page={{ page_obj.previous_page_number }}&{{ request.GET.urlencode }}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||
@@ -68,8 +138,16 @@
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
|
||||
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||
<a href="?page={{ page_obj.next_page_number }}&{{ request.GET.urlencode }}"
|
||||
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
hx-get="?page={{ page_obj.next_page_number }}&{{ request.GET.urlencode }}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true">Next</a>
|
||||
<a href="?page={{ page_obj.paginator.num_pages }}&{{ request.GET.urlencode }}"
|
||||
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
hx-get="?page={{ page_obj.paginator.num_pages }}&{{ request.GET.urlencode }}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true">Last »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
c# Active Context
|
||||
|
||||
## Current Focus
|
||||
- **✅ COMPLETED: Parks and Rides API 501 Error Fix**: Successfully resolved 501 errors in both parks and rides listing endpoints by fixing import paths from `apps.companies.models` to `apps.parks.models` and resolving annotation conflicts with existing model fields
|
||||
- **✅ COMPLETED: Park Filter Endpoints Backend-Frontend Alignment**: Successfully resolved critical backend-frontend alignment issue where Django backend was filtering on non-existent model fields
|
||||
- **✅ COMPLETED: Automatic Cloudflare Image Deletion**: Successfully implemented automatic Cloudflare image deletion across all photo upload systems (avatar, park photos, ride photos) when users change or remove images
|
||||
- **✅ COMPLETED: Photo Upload System Consistency**: Successfully extended avatar upload fix to park and ride photo uploads, ensuring all photo upload systems work consistently with proper Cloudflare variants extraction
|
||||
|
||||
181
docs/parks-rides-api-501-fix-llm-prompt.md
Normal file
181
docs/parks-rides-api-501-fix-llm-prompt.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Parks and Rides API 501 Error Fix - Frontend Integration Prompt
|
||||
|
||||
## Project Context
|
||||
ThrillWiki is a comprehensive theme park and ride database with Django REST API backend. The parks and rides listing endpoints were returning 501 errors due to incorrect model imports, preventing frontend integration.
|
||||
|
||||
## High-Level Objectives
|
||||
- Fix 501 errors in both `/api/v1/parks/` and `/api/v1/rides/` endpoints
|
||||
- Ensure proper model imports and database query functionality
|
||||
- Maintain existing API contract and response formats
|
||||
- Verify all filtering and search functionality works correctly
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Root Cause Analysis
|
||||
Both API endpoints were attempting to import the Company model from a non-existent `apps.companies.models` module. The Company model is actually located in `apps.parks.models`.
|
||||
|
||||
### Backend Changes Applied
|
||||
|
||||
#### Parks API (`backend/apps/api/v1/parks/park_views.py`)
|
||||
- **Import Fix**: Changed from `from apps.companies.models import Company` to `from apps.parks.models import Park, Company`
|
||||
- **Annotation Conflicts**: Resolved database annotation conflicts by using calculated field names to avoid conflicts with existing model fields
|
||||
- **Query Optimization**: Maintained existing select_related and prefetch_related optimizations
|
||||
|
||||
#### Rides API (`backend/apps/api/v1/rides/views.py`)
|
||||
- **Import Fix**: Simplified imports to use single Company model from parks app
|
||||
- **Variable References**: Updated all Company references to use correct import
|
||||
- **Maintained Functionality**: All filtering, search, and pagination features preserved
|
||||
|
||||
### API Endpoints Now Functional
|
||||
|
||||
#### Parks API Endpoints
|
||||
- `GET /api/v1/parks/` - List parks with comprehensive filtering and pagination
|
||||
- `GET /api/v1/parks/filter-options/` - Get filter metadata for frontend forms
|
||||
- `GET /api/v1/parks/search/companies/?q={query}` - Company autocomplete search
|
||||
- `GET /api/v1/parks/search-suggestions/?q={query}` - Park name suggestions
|
||||
- `GET /api/v1/parks/{id}/` - Individual park details
|
||||
- `PATCH /api/v1/parks/{id}/image-settings/` - Set banner/card images
|
||||
|
||||
#### Rides API Endpoints
|
||||
- `GET /api/v1/rides/` - List rides with comprehensive filtering and pagination
|
||||
- `GET /api/v1/rides/filter-options/` - Get filter metadata for frontend forms
|
||||
- `GET /api/v1/rides/search/companies/?q={query}` - Company autocomplete search
|
||||
- `GET /api/v1/rides/search/ride-models/?q={query}` - Ride model autocomplete
|
||||
- `GET /api/v1/rides/search-suggestions/?q={query}` - Ride name suggestions
|
||||
- `GET /api/v1/rides/{id}/` - Individual ride details
|
||||
- `PATCH /api/v1/rides/{id}/image-settings/` - Set banner/card images
|
||||
|
||||
### Mandatory API Rules Compliance
|
||||
- **Trailing Forward Slashes**: All API endpoints include mandatory trailing forward slashes
|
||||
- **HTTP Methods**: Proper GET/POST/PATCH/DELETE method usage
|
||||
- **Authentication**: Public endpoints use AllowAny permissions
|
||||
- **Error Handling**: Proper 404/400/500 error responses with detailed messages
|
||||
- **Pagination**: Standard pagination with count, next, previous fields
|
||||
|
||||
### Response Format Examples
|
||||
|
||||
#### Parks List Response
|
||||
```json
|
||||
{
|
||||
"count": 7,
|
||||
"next": "http://localhost:8000/api/v1/parks/?page=2&page_size=2",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 99,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"status": "OPERATING",
|
||||
"description": "Known as the \"Roller Coaster Capital of the World\".",
|
||||
"average_rating": null,
|
||||
"coaster_count": 4,
|
||||
"ride_count": 4,
|
||||
"location": {
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"city": null,
|
||||
"state": null,
|
||||
"country": null,
|
||||
"formatted_address": ""
|
||||
},
|
||||
"operator": {
|
||||
"id": 114,
|
||||
"name": "Cedar Fair Entertainment Company",
|
||||
"slug": "cedar-fair-entertainment-company",
|
||||
"roles": ["OPERATOR"],
|
||||
"url": ""
|
||||
},
|
||||
"url": "http://www.thrillwiki.com/parks/cedar-point/",
|
||||
"created_at": "2025-08-22T15:33:27.302477-04:00",
|
||||
"updated_at": "2025-08-28T19:13:11.773038-04:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Rides List Response
|
||||
```json
|
||||
{
|
||||
"count": 10,
|
||||
"next": "http://localhost:8000/api/v1/rides/?page=2&page_size=2",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 134,
|
||||
"name": "Big Thunder Mountain Railroad",
|
||||
"slug": "big-thunder-mountain-railroad",
|
||||
"category": "RC",
|
||||
"status": "OPERATING",
|
||||
"description": "Mine train roller coaster themed as a runaway mining train.",
|
||||
"park": {
|
||||
"id": 97,
|
||||
"name": "Magic Kingdom",
|
||||
"slug": "magic-kingdom"
|
||||
},
|
||||
"average_rating": null,
|
||||
"capacity_per_hour": null,
|
||||
"opening_date": "1980-11-15",
|
||||
"closing_date": null,
|
||||
"url": "http://www.thrillwiki.com/parks/magic-kingdom/rides/big-thunder-mountain-railroad/",
|
||||
"created_at": "2025-08-22T15:33:27.326714-04:00",
|
||||
"updated_at": "2025-08-28T19:13:11.752830-04:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Filter Options Response Structure
|
||||
Both parks and rides filter options endpoints return comprehensive metadata including:
|
||||
- **Categories**: Available ride/park categories with labels
|
||||
- **Statuses**: Operational status options
|
||||
- **Ordering Options**: Sort field options with human-readable labels
|
||||
- **Filter Ranges**: Min/max/step values for numeric filters
|
||||
- **Boolean Filters**: Toggle filter definitions
|
||||
|
||||
### Frontend Integration Requirements
|
||||
|
||||
#### TypeScript Integration
|
||||
- All endpoints return properly typed responses
|
||||
- Company model unified across parks and rides domains
|
||||
- Consistent error handling patterns
|
||||
- Proper pagination interface implementation
|
||||
|
||||
#### State Management Patterns
|
||||
- Handle loading states during API calls
|
||||
- Implement proper error boundaries for 404/500 responses
|
||||
- Cache filter options to reduce API calls
|
||||
- Debounce search/autocomplete queries
|
||||
|
||||
#### User Experience Recommendations
|
||||
- Show loading indicators during data fetching
|
||||
- Implement infinite scroll or pagination controls
|
||||
- Provide clear error messages for failed requests
|
||||
- Use autocomplete for company and ride model searches
|
||||
|
||||
### Performance Optimization Strategies
|
||||
- **Database Queries**: All endpoints use optimized select_related and prefetch_related
|
||||
- **Caching**: Filter options can be cached client-side
|
||||
- **Pagination**: Use appropriate page sizes (default 20, max 1000)
|
||||
- **Search Debouncing**: Implement 300ms debounce for search queries
|
||||
|
||||
### Testing Considerations
|
||||
- Verify all endpoints return 200 status codes
|
||||
- Test pagination with various page sizes
|
||||
- Validate filter combinations work correctly
|
||||
- Ensure search functionality returns relevant results
|
||||
- Test error handling for invalid parameters
|
||||
|
||||
### Backend Compatibility Notes
|
||||
- **Fully Supported**: All documented endpoints are fully functional
|
||||
- **Real Database Queries**: All responses use actual database data, no mock responses
|
||||
- **Consistent Response Format**: All endpoints follow DRF pagination standards
|
||||
- **Error Handling**: Proper HTTP status codes and error messages
|
||||
|
||||
### Documentation Maintenance
|
||||
This fix resolves the 501 errors and restores full functionality to both parks and rides API endpoints. All existing frontend integration patterns should continue to work without modification.
|
||||
|
||||
### Version Information
|
||||
- **Fix Applied**: 2025-08-31
|
||||
- **Django Version**: Compatible with current project setup
|
||||
- **API Version**: v1 (stable)
|
||||
- **Breaking Changes**: None - maintains existing API contract
|
||||
Reference in New Issue
Block a user