mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 03:31:08 -05:00
Add advanced search and trending parks features; update frontend dependencies and enhance home page layout
This commit is contained in:
@@ -12,8 +12,8 @@ tags: ["django", "architecture", "context7-integration", "thrillwiki"]
|
|||||||
Theme park database platform with Django REST Framework serving 120+ API endpoints for parks, rides, companies, and users.
|
Theme park database platform with Django REST Framework serving 120+ API endpoints for parks, rides, companies, and users.
|
||||||
|
|
||||||
## Core Architecture
|
## Core Architecture
|
||||||
- **Backend**: Django 5.0+, DRF, PostgreSQL+PostGIS, Redis, Celery
|
- **Backend**: Django 5.1+, DRF, PostgreSQL+PostGIS, Redis, Celery
|
||||||
- **Frontend**: HTMX + AlpineJS + Tailwind CSS + Django-Cotton
|
- **Frontend**: HTMX (V2+) + AlpineJS + Tailwind CSS (V4+) + Django-Cotton
|
||||||
- 🚨 **ABSOLUTELY NO Custom JS** - use HTMX + AlpineJS ONLY
|
- 🚨 **ABSOLUTELY NO Custom JS** - use HTMX + AlpineJS ONLY
|
||||||
- Clean, simple UX preferred
|
- Clean, simple UX preferred
|
||||||
- **Media**: Cloudflare Images with Direct Upload
|
- **Media**: Cloudflare Images with Direct Upload
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from apps.core.views.search import (
|
|||||||
FilterFormView,
|
FilterFormView,
|
||||||
LocationSearchView,
|
LocationSearchView,
|
||||||
LocationSuggestionsView,
|
LocationSuggestionsView,
|
||||||
|
AdvancedSearchView,
|
||||||
)
|
)
|
||||||
from apps.rides.views import RideSearchView
|
from apps.rides.views import RideSearchView
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ app_name = "search"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("parks/", AdaptiveSearchView.as_view(), name="search"),
|
path("parks/", AdaptiveSearchView.as_view(), name="search"),
|
||||||
path("parks/filters/", FilterFormView.as_view(), name="filter_form"),
|
path("parks/filters/", FilterFormView.as_view(), name="filter_form"),
|
||||||
|
path("advanced/", AdvancedSearchView.as_view(), name="advanced"),
|
||||||
path("rides/", RideSearchView.as_view(), name="ride_search"),
|
path("rides/", RideSearchView.as_view(), name="ride_search"),
|
||||||
path("rides/results/", RideSearchView.as_view(), name="ride_search_results"),
|
path("rides/results/", RideSearchView.as_view(), name="ride_search_results"),
|
||||||
# Location-aware search
|
# Location-aware search
|
||||||
|
|||||||
@@ -176,3 +176,43 @@ class LocationSuggestionsView(TemplateView):
|
|||||||
return JsonResponse({"suggestions": suggestions})
|
return JsonResponse({"suggestions": suggestions})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({"error": str(e)}, status=500)
|
return JsonResponse({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class AdvancedSearchView(TemplateView):
|
||||||
|
"""Advanced search view with comprehensive filtering options for both parks and rides"""
|
||||||
|
template_name = "core/search/advanced.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from apps.parks.filters import ParkFilter
|
||||||
|
from apps.rides.filters import RideFilter
|
||||||
|
from apps.parks.models import Park
|
||||||
|
from apps.rides.models.rides import Ride
|
||||||
|
|
||||||
|
# Initialize filtersets for both parks and rides
|
||||||
|
park_filterset = ParkFilter(self.request.GET, queryset=Park.objects.all())
|
||||||
|
ride_filterset = RideFilter(self.request.GET, queryset=Ride.objects.all())
|
||||||
|
|
||||||
|
# Determine what type of search to show based on request parameters
|
||||||
|
search_type = self.request.GET.get('search_type', 'parks') # Default to parks
|
||||||
|
|
||||||
|
context.update({
|
||||||
|
'page_title': 'Advanced Search',
|
||||||
|
'page_description': 'Find exactly what you\'re looking for with our comprehensive search filters.',
|
||||||
|
'search_type': search_type,
|
||||||
|
'park_filters': park_filterset,
|
||||||
|
'ride_filters': ride_filterset,
|
||||||
|
'park_results': park_filterset.qs if search_type == 'parks' else None,
|
||||||
|
'ride_results': ride_filterset.qs if search_type == 'rides' else None,
|
||||||
|
'has_filters': bool(self.request.GET),
|
||||||
|
})
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_template_names(self):
|
||||||
|
"""Return appropriate template for HTMX requests"""
|
||||||
|
if hasattr(self.request, 'htmx') and self.request.htmx:
|
||||||
|
return ["core/search/partials/advanced_results.html"]
|
||||||
|
return [self.template_name]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ app_name = "parks"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Park views with autocomplete search
|
# Park views with autocomplete search
|
||||||
path("", views.ParkListView.as_view(), name="park_list"),
|
path("", views.ParkListView.as_view(), name="park_list"),
|
||||||
|
path("trending/", views.TrendingParksView.as_view(), name="trending"),
|
||||||
path("operators/", views.OperatorListView.as_view(), name="operator_list"),
|
path("operators/", views.OperatorListView.as_view(), name="operator_list"),
|
||||||
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
||||||
# Add park button endpoint (moved before park detail pattern)
|
# Add park button endpoint (moved before park detail pattern)
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ from django.urls import reverse
|
|||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from decimal import InvalidOperation
|
from decimal import InvalidOperation
|
||||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||||
|
from django.db.models import Count, Avg, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
import requests
|
import requests
|
||||||
from decimal import Decimal, ROUND_DOWN
|
from decimal import Decimal, ROUND_DOWN
|
||||||
from typing import Any, Optional, cast, Literal, Dict
|
from typing import Any, Optional, cast, Literal, Dict
|
||||||
@@ -224,6 +227,56 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
|||||||
return JsonResponse({"error": "Geocoding failed"}, status=500)
|
return JsonResponse({"error": "Geocoding failed"}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class TrendingParksView(ListView):
|
||||||
|
"""View for displaying trending/popular parks"""
|
||||||
|
model = Park
|
||||||
|
template_name = "parks/trending_parks.html"
|
||||||
|
context_object_name = "parks"
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self) -> QuerySet[Park]:
|
||||||
|
"""Get trending parks based on ride count, ratings, and recent activity"""
|
||||||
|
# For now, order by a combination of factors that indicate popularity:
|
||||||
|
# 1. Parks with more rides
|
||||||
|
# 2. Higher average ratings
|
||||||
|
# 3. More recent activity (reviews, photos, etc.)
|
||||||
|
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||||
|
|
||||||
|
return (
|
||||||
|
get_base_park_queryset()
|
||||||
|
.annotate(
|
||||||
|
recent_reviews=Count(
|
||||||
|
'reviews',
|
||||||
|
filter=Q(reviews__created_at__gte=thirty_days_ago)
|
||||||
|
),
|
||||||
|
recent_photos=Count(
|
||||||
|
'photos',
|
||||||
|
filter=Q(photos__created_at__gte=thirty_days_ago)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
'-recent_reviews',
|
||||||
|
'-recent_photos',
|
||||||
|
'-ride_count',
|
||||||
|
'-average_rating'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_template_names(self) -> list[str]:
|
||||||
|
"""Return appropriate template for HTMX requests"""
|
||||||
|
if self.request.htmx:
|
||||||
|
return ["parks/partials/trending_parks.html"]
|
||||||
|
return [self.template_name]
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context.update({
|
||||||
|
'page_title': 'Trending Parks',
|
||||||
|
'page_description': 'Discover the most popular theme parks with recent activity and high ratings.'
|
||||||
|
})
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class ParkListView(HTMXFilterableMixin, ListView):
|
class ParkListView(HTMXFilterableMixin, ListView):
|
||||||
model = Park
|
model = Park
|
||||||
template_name = "parks/enhanced_park_list.html"
|
template_name = "parks/enhanced_park_list.html"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ app_name = "rides"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Global list views
|
# Global list views
|
||||||
path("", views.RideListView.as_view(), name="global_ride_list"),
|
path("", views.RideListView.as_view(), name="global_ride_list"),
|
||||||
|
path("new/", views.NewRidesView.as_view(), name="new"),
|
||||||
# Global category views
|
# Global category views
|
||||||
path(
|
path(
|
||||||
"roller-coasters/",
|
"roller-coasters/",
|
||||||
|
|||||||
@@ -302,6 +302,37 @@ class RideListView(ListView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class NewRidesView(ListView):
|
||||||
|
"""View for displaying recently added rides"""
|
||||||
|
model = Ride
|
||||||
|
template_name = "rides/new_rides.html"
|
||||||
|
context_object_name = "rides"
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Get recently added rides, ordered by creation date"""
|
||||||
|
return (
|
||||||
|
Ride.objects.all()
|
||||||
|
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||||
|
.prefetch_related("photos")
|
||||||
|
.order_by("-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_template_names(self):
|
||||||
|
"""Return appropriate template for HTMX requests"""
|
||||||
|
if hasattr(self.request, "htmx") and self.request.htmx:
|
||||||
|
return ["rides/partials/new_rides.html"]
|
||||||
|
return [self.template_name]
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context.update({
|
||||||
|
'page_title': 'New Attractions',
|
||||||
|
'page_description': 'Discover the latest rides and attractions added to theme parks around the world.'
|
||||||
|
})
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class SingleCategoryListView(ListView):
|
class SingleCategoryListView(ListView):
|
||||||
"""View for displaying rides of a specific category"""
|
"""View for displaying rides of a specific category"""
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@
|
|||||||
<!-- Preload Critical Resources -->
|
<!-- Preload Critical Resources -->
|
||||||
{% block critical_resources %}
|
{% block critical_resources %}
|
||||||
<link rel="preload" href="{% static 'css/tailwind.css' %}" as="style" />
|
<link rel="preload" href="{% static 'css/tailwind.css' %}" as="style" />
|
||||||
<link rel="preload" href="{% static 'js/alpine.min.js' %}?v={{ version|default:'1.0' }}" as="script" />
|
|
||||||
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" as="style" />
|
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" as="style" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -62,10 +61,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- HTMX -->
|
<!-- HTMX -->
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js" integrity="sha384-yWakaGAFicqusuwOYEmoRjLNOC+6OFsdmwC2lbGQaRELtuVEqNzt11c2J711DeCZ" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<!-- Alpine.js (must load after components) -->
|
<!-- Alpine.js (must load after components) -->
|
||||||
<script defer src="{% static 'js/alpine.min.js' %}?v={{ version|default:'1.0' }}"></script>
|
<script src="//unpkg.com/alpinejs" defer></script>
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
<!-- Tailwind CSS -->
|
||||||
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
|
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
|
||||||
@@ -173,10 +172,29 @@
|
|||||||
<c-toast_container />
|
<c-toast_container />
|
||||||
|
|
||||||
<!-- AlpineJS Global Configuration (Compliant with HTMX + AlpineJS Only Rule) -->
|
<!-- AlpineJS Global Configuration (Compliant with HTMX + AlpineJS Only Rule) -->
|
||||||
<div x-data="{}" x-init="
|
<script>
|
||||||
// Configure HTMX globally
|
document.addEventListener('alpine:init', () => {
|
||||||
|
// Configure HTMX 2.x globally with proper defaults
|
||||||
htmx.config.globalViewTransitions = true;
|
htmx.config.globalViewTransitions = true;
|
||||||
|
|
||||||
|
// HTMX 2.x Migration: Maintain 1.x behavior for smooth scrolling
|
||||||
|
htmx.config.scrollBehavior = 'smooth';
|
||||||
|
|
||||||
|
// HTMX 2.x Migration: Keep DELETE requests using form-encoded body (like 1.x)
|
||||||
|
htmx.config.methodsThatUseUrlParams = ["get"];
|
||||||
|
|
||||||
|
// HTMX 2.x Migration: Allow cross-domain requests (like 1.x)
|
||||||
|
htmx.config.selfRequestsOnly = false;
|
||||||
|
|
||||||
|
// Enhanced HTMX event handling for better UX
|
||||||
|
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||||
|
// Add CSRF token to all HTMX requests
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (csrfToken) {
|
||||||
|
evt.detail.headers['X-CSRFToken'] = csrfToken.getAttribute('content');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize Alpine stores
|
// Initialize Alpine stores
|
||||||
Alpine.store('app', {
|
Alpine.store('app', {
|
||||||
user: null,
|
user: null,
|
||||||
@@ -206,7 +224,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
" style="display: none;"></div>
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -51,9 +51,9 @@
|
|||||||
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-thrill-primary to-purple-500 transition-all duration-300 group-hover:w-full"></span>
|
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-thrill-primary to-purple-500 transition-all duration-300 group-hover:w-full"></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{% url 'rides:list' %}"
|
<a href="{% url 'rides:global_ride_list' %}"
|
||||||
class="nav-link group relative"
|
class="nav-link group relative"
|
||||||
hx-get="{% url 'rides:list' %}"
|
hx-get="{% url 'rides:global_ride_list' %}"
|
||||||
hx-target="#main-content"
|
hx-target="#main-content"
|
||||||
hx-swap="innerHTML transition:true">
|
hx-swap="innerHTML transition:true">
|
||||||
<i class="fas fa-rocket mr-2 text-thrill-secondary"></i>
|
<i class="fas fa-rocket mr-2 text-thrill-secondary"></i>
|
||||||
@@ -367,7 +367,7 @@
|
|||||||
<span class="font-medium">Parks</span>
|
<span class="font-medium">Parks</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{% url 'rides:ride_list' %}"
|
<a href="{% url 'rides:global_ride_list' %}"
|
||||||
class="flex items-center p-3 rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-700/50 transition-colors"
|
class="flex items-center p-3 rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-700/50 transition-colors"
|
||||||
@click="isOpen = false">
|
@click="isOpen = false">
|
||||||
<i class="fas fa-rocket mr-3 text-thrill-secondary"></i>
|
<i class="fas fa-rocket mr-3 text-thrill-secondary"></i>
|
||||||
|
|||||||
@@ -98,46 +98,67 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Featured Parks Grid -->
|
<!-- Featured Parks Grid -->
|
||||||
<div class="grid-auto-fit-lg"
|
<div class="grid-auto-fit-lg">
|
||||||
hx-get="/api/parks/featured/"
|
<!-- Static placeholder content -->
|
||||||
hx-trigger="revealed"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- Loading Skeletons -->
|
|
||||||
<div class="card hover-lift">
|
<div class="card hover-lift">
|
||||||
<div class="loading-skeleton aspect-video rounded-t-2xl"></div>
|
<div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-primary to-purple-500 flex items-center justify-center">
|
||||||
<div class="p-6 space-y-4">
|
<i class="fas fa-map-marked-alt text-4xl text-white"></i>
|
||||||
<div class="loading-skeleton h-6 w-3/4 rounded"></div>
|
</div>
|
||||||
<div class="loading-skeleton h-4 w-full rounded"></div>
|
<div class="p-6">
|
||||||
<div class="loading-skeleton h-4 w-2/3 rounded"></div>
|
<h3 class="text-xl font-bold mb-2">Explore Amazing Parks</h3>
|
||||||
|
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
|
Discover incredible theme parks from around the world with detailed guides and insider tips.
|
||||||
|
</p>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="loading-skeleton h-6 w-20 rounded-full"></div>
|
<span class="badge badge-primary">Featured</span>
|
||||||
<div class="loading-skeleton h-8 w-24 rounded-lg"></div>
|
<button class="btn-primary btn-sm"
|
||||||
|
hx-get="/parks/"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-swap="innerHTML transition:true">
|
||||||
|
View All
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card hover-lift">
|
<div class="card hover-lift">
|
||||||
<div class="loading-skeleton aspect-video rounded-t-2xl"></div>
|
<div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-secondary to-red-500 flex items-center justify-center">
|
||||||
<div class="p-6 space-y-4">
|
<i class="fas fa-rocket text-4xl text-white"></i>
|
||||||
<div class="loading-skeleton h-6 w-3/4 rounded"></div>
|
</div>
|
||||||
<div class="loading-skeleton h-4 w-full rounded"></div>
|
<div class="p-6">
|
||||||
<div class="loading-skeleton h-4 w-2/3 rounded"></div>
|
<h3 class="text-xl font-bold mb-2">Thrilling Rides</h3>
|
||||||
|
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
|
From heart-pounding roller coasters to magical dark rides, find your next adventure.
|
||||||
|
</p>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="loading-skeleton h-6 w-20 rounded-full"></div>
|
<span class="badge badge-secondary">Popular</span>
|
||||||
<div class="loading-skeleton h-8 w-24 rounded-lg"></div>
|
<button class="btn-secondary btn-sm"
|
||||||
|
hx-get="/rides/"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-swap="innerHTML transition:true">
|
||||||
|
Explore
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card hover-lift">
|
<div class="card hover-lift">
|
||||||
<div class="loading-skeleton aspect-video rounded-t-2xl"></div>
|
<div class="aspect-video rounded-t-2xl bg-gradient-to-br from-thrill-success to-teal-500 flex items-center justify-center">
|
||||||
<div class="p-6 space-y-4">
|
<i class="fas fa-search text-4xl text-white"></i>
|
||||||
<div class="loading-skeleton h-6 w-3/4 rounded"></div>
|
</div>
|
||||||
<div class="loading-skeleton h-4 w-full rounded"></div>
|
<div class="p-6">
|
||||||
<div class="loading-skeleton h-4 w-2/3 rounded"></div>
|
<h3 class="text-xl font-bold mb-2">Advanced Search</h3>
|
||||||
|
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
|
Find exactly what you're looking for with our powerful search and filtering tools.
|
||||||
|
</p>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="loading-skeleton h-6 w-20 rounded-full"></div>
|
<span class="badge badge-success">Tools</span>
|
||||||
<div class="loading-skeleton h-8 w-24 rounded-lg"></div>
|
<button class="btn-success btn-sm"
|
||||||
|
hx-get="/search/"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-swap="innerHTML transition:true">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user