mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 09:11: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>
|
||||
|
||||
Reference in New Issue
Block a user