mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 13:51:09 -05:00
Refactor API structure and add comprehensive user management features
- Restructure API v1 with improved serializers organization - Add user deletion requests and moderation queue system - Implement bulk moderation operations and permissions - Add user profile enhancements with display names and avatars - Expand ride and park API endpoints with better filtering - Add manufacturer API with detailed ride relationships - Improve authentication flows and error handling - Update frontend documentation and API specifications
This commit is contained in:
@@ -1,909 +0,0 @@
|
||||
{# Enhanced filter form with modern UI/UX design - timestamp: 2025-08-21 #}
|
||||
{% load static %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<!-- Mobile Filter Toggle Button -->
|
||||
<button class="mobile-nav-toggle md:hidden fixed bottom-6 right-6 z-50 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-4 focus:ring-blue-500/50"
|
||||
onclick="toggleMobileFilters()"
|
||||
aria-label="Toggle filters"
|
||||
data-tooltip="Show/Hide Filters">
|
||||
<svg class="w-6 h-6 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="filter-container" x-data="filterManager()" x-init="init()">
|
||||
{# Quick Filter Presets - Enhanced Design #}
|
||||
<div class="mb-8 bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 p-6 backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-blue-500 to-purple-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Quick Filters</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Find parks instantly with preset filters</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Live filtering</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('disney')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('disney') ? 'bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg ring-2 ring-blue-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Show Disney Parks">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" 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>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('disney')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Disney Parks</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.disney" x-text="`${quickFilterCounts.disney} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-400/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('coasters')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('coasters') ? 'bg-gradient-to-br from-green-500 to-green-600 text-white shadow-lg ring-2 ring-green-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-green-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Parks with Roller Coasters">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-red-400 rounded-full animate-ping" x-show="isQuickFilterActive('coasters')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">With Coasters</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.coasters" x-text="`${quickFilterCounts.coasters} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-green-400/10 to-emerald-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('top_rated')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('top_rated') ? 'bg-gradient-to-br from-yellow-500 to-yellow-600 text-white shadow-lg ring-2 ring-yellow-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-yellow-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Highly Rated Parks (4+ stars)">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" 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>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('top_rated')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Top Rated</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.top_rated" x-text="`${quickFilterCounts.top_rated} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-yellow-400/10 to-orange-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('major_parks')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('major_parks') ? 'bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-lg ring-2 ring-purple-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-purple-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Major Theme Parks (10+ rides)">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-purple-400 rounded-full animate-ping" x-show="isQuickFilterActive('major_parks')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Major Parks</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.major_parks" x-text="`${quickFilterCounts.major_parks} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-purple-400/10 to-pink-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Mobile Filter Toggle - Enhanced Design #}
|
||||
<div class="lg:hidden mb-6">
|
||||
<button type="button"
|
||||
@click="showMobileFilters = !showMobileFilters"
|
||||
class="w-full group relative overflow-hidden rounded-xl p-4 bg-gradient-to-r from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 border border-gray-200/50 dark:border-gray-700/50 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-blue-500/10 dark:bg-blue-400/10 rounded-lg">
|
||||
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-lg font-bold text-gray-900 dark:text-white">Filters</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Customize your search</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 rounded-full">
|
||||
<span class="text-xs font-semibold text-blue-800 dark:text-blue-300" x-text="activeFilterCount + ' active'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-6 h-6 text-gray-400 transition-transform duration-300" :class="showMobileFilters ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Main Filter Form - Enhanced Design #}
|
||||
<form hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="change delay:300ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML transition:true"
|
||||
class="space-y-8"
|
||||
:class="showMobileFilters || window.innerWidth >= 1024 ? 'block' : 'hidden'"
|
||||
x-show.transition="showMobileFilters || window.innerWidth >= 1024">
|
||||
|
||||
{# Search Input Section - Enhanced Design #}
|
||||
<div class="bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-850 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 p-6 backdrop-blur-sm">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-green-500 to-blue-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Search & Discover</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Find parks by name, location, or features</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-xl blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-500"></div>
|
||||
<div class="relative">
|
||||
<label for="search" class="sr-only">Search Parks</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="search"
|
||||
name="search"
|
||||
x-model="searchQuery"
|
||||
placeholder="Search by park name, location, or features..."
|
||||
class="w-full px-6 py-4 pl-14 pr-12 text-lg border-2 border-gray-200 dark:border-gray-600 rounded-xl bg-white/80 dark:bg-gray-800/80 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-300 ease-out backdrop-blur-sm hover:border-gray-300 dark:hover:border-gray-500 group-focus-within:shadow-xl">
|
||||
|
||||
{# Search Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<div class="p-1 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<svg class="h-6 w-6 text-blue-600 dark:text-blue-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>
|
||||
|
||||
{# Clear Button #}
|
||||
<div class="absolute inset-y-0 right-0 pr-4 flex items-center" x-show="searchQuery" x-transition>
|
||||
<button type="button"
|
||||
@click="searchQuery = ''"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
title="Clear search">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Loading Indicator #}
|
||||
<div class="absolute inset-y-0 right-0 pr-4 flex items-center" x-show="isLoading" x-transition>
|
||||
<div class="animate-spin h-5 w-5 text-blue-500">
|
||||
<svg 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>
|
||||
|
||||
{# Search Suggestions/Hints #}
|
||||
<div class="mt-3 flex flex-wrap gap-2" x-show="!searchQuery">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Popular searches:</div>
|
||||
<button type="button" @click="searchQuery = 'Disney'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Disney</button>
|
||||
<button type="button" @click="searchQuery = 'roller coaster'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Roller Coaster</button>
|
||||
<button type="button" @click="searchQuery = 'California'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">California</button>
|
||||
<button type="button" @click="searchQuery = 'water park'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Water Park</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Loading Indicator #}
|
||||
<div id="loading-indicator" class="htmx-indicator fixed top-4 right-4 z-50">
|
||||
<div class="bg-gradient-to-r from-blue-600 to-blue-700 text-white px-6 py-3 rounded-xl shadow-2xl border border-blue-500/20 backdrop-blur-sm">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="animate-spin h-5 w-5 text-white">
|
||||
<svg 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>
|
||||
<span class="font-medium">Updating results...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active Filters Summary - Enhanced #}
|
||||
<div x-show="activeFilterCount > 0"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 transform scale-100 translate-y-0"
|
||||
class="bg-gradient-to-r from-blue-50 via-indigo-50 to-purple-50 dark:from-blue-900/20 dark:via-indigo-900/20 dark:to-purple-900/20 rounded-xl p-5 shadow-lg border border-blue-200/50 dark:border-blue-700/50 backdrop-blur-sm">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-800/30 rounded-lg">
|
||||
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-blue-900 dark:text-blue-100">Active Filters</h3>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300" x-text="`${activeFilterCount} filter${activeFilterCount !== 1 ? 's' : ''} applied`"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
@click="clearAllFilters()"
|
||||
class="px-4 py-2 text-sm font-semibold text-blue-700 dark:text-blue-300 bg-white/80 dark:bg-gray-800/80 hover:bg-blue-100 dark:hover:bg-blue-800/50 rounded-lg border border-blue-200 dark:border-blue-600 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 hover:shadow-md"
|
||||
aria-label="Clear all filters">
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Filter Groups #}
|
||||
<div class="bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-850 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm">
|
||||
<div class="p-6" x-data="{ expanded: true }">
|
||||
{# Enhanced Group Header #}
|
||||
<button type="button"
|
||||
@click="expanded = !expanded"
|
||||
class="w-full group flex justify-between items-center text-left p-3 -m-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200"
|
||||
:aria-expanded="expanded"
|
||||
aria-controls="filter-group-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-purple-500 to-pink-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">Advanced Filters</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Refine your search with detailed criteria</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">Advanced</span>
|
||||
</div>
|
||||
<svg class="w-6 h-6 text-gray-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 transform transition-all duration-300"
|
||||
:class="{'rotate-180': !expanded}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{# Enhanced Group Content #}
|
||||
<div id="filter-group-1"
|
||||
class="mt-6 space-y-6"
|
||||
x-show="expanded"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 max-h-0"
|
||||
x-transition:enter-end="opacity-100 max-h-screen"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 max-h-screen"
|
||||
x-transition:leave-end="opacity-0 max-h-0">
|
||||
{% for field in filter.form %}
|
||||
<div class="filter-field group">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="flex items-center justify-between text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
{# Enhanced Field Icons Based on Type #}
|
||||
<div class="p-2 bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-900/30 dark:to-purple-900/30 rounded-lg border border-blue-200/50 dark:border-blue-700/50">
|
||||
{% if field.name == 'search' or field.field.widget.input_type == 'search' %}
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-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>
|
||||
{% elif 'location' in field.name or 'city' in field.name or 'state' in field.name or 'country' in field.name %}
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" 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>
|
||||
{% elif 'date' in field.name or field.field.widget.input_type == 'date' %}
|
||||
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% elif 'operator' in field.name or 'company' in field.name %}
|
||||
<svg class="w-4 h-4 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{% elif 'rating' in field.name or 'star' in field.name %}
|
||||
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||||
</svg>
|
||||
{% elif 'count' in field.name or field.field.widget.input_type == 'number' %}
|
||||
<svg class="w-4 h-4 text-teal-600 dark:text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
{% elif field.field.widget.input_type == 'checkbox' %}
|
||||
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% elif field.field.widget.input_type == 'select' %}
|
||||
<svg class="w-4 h-4 text-pink-600 dark:text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span>{{ field.label }}</span>
|
||||
{% if field.help_text %}
|
||||
<button type="button"
|
||||
class="text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 focus:outline-none transition-colors duration-200"
|
||||
@click="$tooltip('{{ field.help_text|escapejs }}', $event)"
|
||||
aria-label="Help for {{ field.label }}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Field Clear Button #}
|
||||
<button type="button"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 dark:hover:text-red-400 focus:outline-none transition-all duration-200 text-xs"
|
||||
@click="clearField('{{ field.id_for_label }}')"
|
||||
aria-label="Clear {{ field.label }}">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'search' %}
|
||||
<div class="relative group/input">
|
||||
<input type="{{ field.field.widget.input_type }}"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
placeholder="{{ field.field.widget.attrs.placeholder|default:'' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 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/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{% if field.name == 'search' or field.field.widget.input_type == 'search' %}
|
||||
<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>
|
||||
{% elif 'location' in field.name or 'city' in field.name %}
|
||||
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'number' %}
|
||||
<div class="relative group/input">
|
||||
<input type="number"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
min="{{ field.field.widget.attrs.min|default:'' }}"
|
||||
max="{{ field.field.widget.attrs.max|default:'' }}"
|
||||
placeholder="{{ field.field.widget.attrs.placeholder|default:'Enter number...' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 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/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<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="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'checkbox' %}
|
||||
<div class="relative group/checkbox">
|
||||
<div class="flex items-center p-4 bg-gradient-to-r from-gray-50 to-white dark:from-gray-700/50 dark:to-gray-800/50 rounded-lg border-2 border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-gradient-to-r hover:from-blue-50 hover:to-purple-50 dark:hover:from-blue-900/20 dark:hover:to-purple-900/20 transition-all duration-200 cursor-pointer">
|
||||
<div class="relative flex items-center">
|
||||
<input type="checkbox"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
{% if field.value %}checked{% endif %}
|
||||
class="h-5 w-5 text-blue-600 focus:ring-2 focus:ring-blue-500/20 border-2 border-gray-300 dark:border-gray-500 dark:bg-gray-600 rounded transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<div class="absolute inset-0 rounded bg-blue-500 opacity-0 group-hover/checkbox:opacity-10 transition-opacity duration-200"></div>
|
||||
</div>
|
||||
<label for="{{ field.id_for_label }}" class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
{# Checkbox Icon #}
|
||||
<div class="ml-auto text-gray-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'select' %}
|
||||
<div class="relative group/select">
|
||||
<select name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
class="block w-full pl-12 pr-10 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/select:shadow-md appearance-none">
|
||||
{% for choice in field.field.choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == field.value %}selected{% endif %}>{{ choice.1 }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{% if 'operator' in field.name %}
|
||||
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<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="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Right Dropdown Arrow #}
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg class="h-5 w-5 text-gray-400 group-hover/select:text-gray-600 dark:group-hover/select:text-gray-300 transition-colors duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'date' %}
|
||||
<div class="relative group/input">
|
||||
<input type="date"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="relative group/input">
|
||||
{{ field|add_class:"block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md" }}
|
||||
{# Left Icon #}
|
||||
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Mobile Apply Button #}
|
||||
<div class="lg:hidden">
|
||||
<button type="submit"
|
||||
class="w-full group relative overflow-hidden bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-6 py-4 rounded-xl font-semibold text-lg shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-300 transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
:disabled="isLoading">
|
||||
<div class="relative z-10 flex items-center justify-center">
|
||||
<span x-show="!isLoading" class="flex items-center space-x-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
<span>Apply Filters</span>
|
||||
</span>
|
||||
<span x-show="isLoading" class="flex items-center space-x-3">
|
||||
<div class="animate-spin h-5 w-5 text-white">
|
||||
<svg 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>
|
||||
<span class="font-medium">Applying Filters...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Enhanced Scripts #}
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('filterManager', () => ({
|
||||
mobileFiltersOpen: false,
|
||||
activeFilterCount: 0,
|
||||
isLoading: false,
|
||||
quickFilterCounts: {
|
||||
disney: 0,
|
||||
coasters: 0,
|
||||
top_rated: 0,
|
||||
major_parks: 0
|
||||
},
|
||||
|
||||
init() {
|
||||
this.updateActiveFilterCount();
|
||||
this.loadQuickFilterCounts();
|
||||
|
||||
// Listen for HTMX events
|
||||
document.addEventListener('htmx:beforeRequest', () => {
|
||||
this.isLoading = true;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:responseError', () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
toggleMobileFilters() {
|
||||
this.mobileFiltersOpen = !this.mobileFiltersOpen;
|
||||
},
|
||||
|
||||
updateActiveFilterCount() {
|
||||
// Count active filters from form inputs
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
let count = 0;
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value && input.value !== '' && input.value !== 'all') {
|
||||
// Skip hidden fields and empty values
|
||||
if (input.type !== 'hidden' && input.value !== '0') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.activeFilterCount = count;
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear all form inputs
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger form submission
|
||||
this.activeFilterCount = 0;
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
applyQuickFilter(filterType) {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear existing filters first
|
||||
this.clearAllFilters();
|
||||
|
||||
// Apply specific filter based on type
|
||||
switch(filterType) {
|
||||
case 'disney':
|
||||
const parkTypeField = form.querySelector('[name="park_type"]');
|
||||
if (parkTypeField) parkTypeField.value = 'disney';
|
||||
break;
|
||||
|
||||
case 'coasters':
|
||||
const coastersField = form.querySelector('[name="has_coasters"]');
|
||||
if (coastersField) coastersField.checked = true;
|
||||
break;
|
||||
|
||||
case 'top_rated':
|
||||
const ratingField = form.querySelector('[name="min_rating"]');
|
||||
if (ratingField) ratingField.value = '4';
|
||||
break;
|
||||
|
||||
case 'major_parks':
|
||||
const bigParksField = form.querySelector('[name="big_parks_only"]');
|
||||
if (bigParksField) bigParksField.checked = true;
|
||||
break;
|
||||
}
|
||||
|
||||
this.updateActiveFilterCount();
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
isQuickFilterActive(filterType) {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return false;
|
||||
|
||||
switch(filterType) {
|
||||
case 'disney':
|
||||
const parkTypeField = form.querySelector('[name="park_type"]');
|
||||
return parkTypeField && parkTypeField.value === 'disney';
|
||||
|
||||
case 'coasters':
|
||||
const coastersField = form.querySelector('[name="has_coasters"]');
|
||||
return coastersField && coastersField.checked;
|
||||
|
||||
case 'top_rated':
|
||||
const ratingField = form.querySelector('[name="min_rating"]');
|
||||
return ratingField && ratingField.value === '4';
|
||||
|
||||
case 'major_parks':
|
||||
const bigParksField = form.querySelector('[name="big_parks_only"]');
|
||||
return bigParksField && bigParksField.checked;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
loadQuickFilterCounts() {
|
||||
// This would typically fetch from an API endpoint
|
||||
// For now, set some placeholder values
|
||||
this.quickFilterCounts = {
|
||||
disney: 12,
|
||||
coasters: 156,
|
||||
top_rated: 89,
|
||||
major_parks: 78
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
// Tooltip directive
|
||||
Alpine.directive('tooltip', (el, { expression }) => {
|
||||
el._tooltip = expression;
|
||||
});
|
||||
|
||||
// Global tooltip function
|
||||
window.$tooltip = function(text, event) {
|
||||
// Simple tooltip implementation - could be enhanced with a library
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'fixed z-50 bg-gray-900 text-white text-sm rounded py-1 px-2 pointer-events-none';
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.left = event.pageX + 10 + 'px';
|
||||
tooltip.style.top = event.pageY - 30 + 'px';
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.parentNode.removeChild(tooltip);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Global field clearing function
|
||||
window.clearField = function(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field) return;
|
||||
|
||||
if (field.type === 'checkbox' || field.type === 'radio') {
|
||||
field.checked = false;
|
||||
} else {
|
||||
field.value = '';
|
||||
}
|
||||
|
||||
// Trigger change event to update filters
|
||||
const form = field.closest('form');
|
||||
if (form) {
|
||||
htmx.trigger(form, 'change');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Enhanced debouncing for text inputs
|
||||
document.addEventListener('input', function(e) {
|
||||
if (e.target.type === 'text' || e.target.type === 'search') {
|
||||
clearTimeout(e.target._debounceTimer);
|
||||
e.target._debounceTimer = setTimeout(() => {
|
||||
htmx.trigger(e.target.form, 'change');
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Mobile filter toggle functionality
|
||||
function toggleMobileFilters() {
|
||||
const filterPanel = document.querySelector('.filter-panel');
|
||||
const backdrop = document.querySelector('.filter-backdrop');
|
||||
|
||||
if (filterPanel) {
|
||||
filterPanel.classList.toggle('is-open');
|
||||
|
||||
// Create backdrop if it doesn't exist
|
||||
if (!backdrop) {
|
||||
const newBackdrop = document.createElement('div');
|
||||
newBackdrop.className = 'filter-backdrop fixed inset-0 bg-black/50 z-40 md:hidden';
|
||||
newBackdrop.onclick = toggleMobileFilters;
|
||||
document.body.appendChild(newBackdrop);
|
||||
} else {
|
||||
backdrop.style.display = filterPanel.classList.contains('is-open') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.classList.toggle('overflow-hidden', filterPanel.classList.contains('is-open'));
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced toast notification system
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toast-container') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification transform transition-all duration-300 ease-out translate-x-full opacity-0 mb-4 p-4 rounded-lg shadow-lg flex items-center space-x-3 ${getToastClasses(type)}`;
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="flex-shrink-0">
|
||||
${getToastIcon(type)}
|
||||
</div>
|
||||
<div class="flex-1 text-sm font-medium">${message}</div>
|
||||
<button onclick="removeToast(this.parentElement)" class="flex-shrink-0 ml-4 text-current opacity-70 hover:opacity-100 transition-opacity">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-x-full', 'opacity-0');
|
||||
}, 100);
|
||||
|
||||
// Auto remove
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(toast), duration);
|
||||
}
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'fixed top-4 right-4 z-50 max-w-sm w-full';
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function removeToast(toast) {
|
||||
toast.classList.add('translate-x-full', 'opacity-0');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
|
||||
function getToastClasses(type) {
|
||||
const classes = {
|
||||
success: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800',
|
||||
error: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800',
|
||||
info: 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800'
|
||||
};
|
||||
return classes[type] || classes.info;
|
||||
}
|
||||
|
||||
function getToastIcon(type) {
|
||||
const icons = {
|
||||
success: `<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
error: `<svg class="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
warning: `<svg class="w-5 h-5 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
info: `<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>`
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
}
|
||||
|
||||
// Enhanced filter manager with additional functionality
|
||||
function enhancedFilterManager() {
|
||||
return {
|
||||
init() {
|
||||
this.setupKeyboardShortcuts();
|
||||
this.setupIntersectionObserver();
|
||||
this.setupTouchGestures();
|
||||
},
|
||||
|
||||
setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Escape to close mobile filters
|
||||
if (e.key === 'Escape') {
|
||||
const filterPanel = document.querySelector('.filter-panel.is-open');
|
||||
if (filterPanel) {
|
||||
toggleMobileFilters();
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + K to focus search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const searchInput = document.querySelector('.search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
showToast('Search focused - start typing!', 'info', 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setupIntersectionObserver() {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('fade-in-scale');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
// Observe park cards for scroll animations
|
||||
document.querySelectorAll('.park-card').forEach(card => {
|
||||
observer.observe(card);
|
||||
});
|
||||
},
|
||||
|
||||
setupTouchGestures() {
|
||||
let startY = 0;
|
||||
let currentY = 0;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
currentY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
const deltaY = startY - currentY;
|
||||
|
||||
// Swipe up to show filters (mobile)
|
||||
if (deltaY > 50 && window.innerWidth < 768) {
|
||||
const filterPanel = document.querySelector('.filter-panel');
|
||||
if (filterPanel && !filterPanel.classList.contains('is-open')) {
|
||||
toggleMobileFilters();
|
||||
showToast('Filters opened', 'info', 1500);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize enhanced functionality when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const enhanced = enhancedFilterManager();
|
||||
enhanced.init();
|
||||
|
||||
// Show welcome message
|
||||
setTimeout(() => {
|
||||
showToast('Welcome! Use Ctrl+K to focus search or swipe up for filters.', 'info', 4000);
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
@@ -1,28 +1,310 @@
|
||||
<form hx-get="{% url 'search:search' %}" hx-target="#search-results" hx-swap="outerHTML" class="space-y-4">
|
||||
{% for field in filters.form %}
|
||||
<div class="flex flex-col">
|
||||
<label for="{{ field.id_for_label }}" class="text-sm font-medium text-gray-700">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ field }}
|
||||
{# Modern Filter Interface - timestamp: 2025-08-29 #}
|
||||
{% load static %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="space-y-6">
|
||||
{# Search Section #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-blue-600 dark:text-blue-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>
|
||||
Search
|
||||
</h3>
|
||||
|
||||
<form hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="input changed delay:500ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="space-y-4">
|
||||
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
name="search"
|
||||
value="{{ request.GET.search|default:'' }}"
|
||||
placeholder="Search parks by name, location..."
|
||||
class="w-full pl-10 pr-4 py-3 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>
|
||||
</form>
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
<p class="text-sm text-gray-500">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Filters Section #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.414A1 1 0 013 6.707V4z"/>
|
||||
</svg>
|
||||
Filters
|
||||
</h3>
|
||||
|
||||
<form id="filter-form"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="change delay:300ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="space-y-6">
|
||||
|
||||
{# Preserve search term #}
|
||||
{% if request.GET.search %}
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% endif %}
|
||||
|
||||
{# Status Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Status
|
||||
</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="">All Status</option>
|
||||
<option value="OPERATING" {% if request.GET.status == 'OPERATING' %}selected{% endif %}>Operating</option>
|
||||
<option value="CLOSED_TEMP" {% if request.GET.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
|
||||
<option value="CLOSED_PERM" {% if request.GET.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
||||
<option value="UNDER_CONSTRUCTION" {% if request.GET.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
|
||||
<option value="DEMOLISHED" {% if request.GET.status == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
|
||||
<option value="RELOCATED" {% if request.GET.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Operator Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Operator
|
||||
</label>
|
||||
<select name="operator"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="">All Operators</option>
|
||||
<option value="cedar-fair" {% if request.GET.operator == 'cedar-fair' %}selected{% endif %}>Cedar Fair</option>
|
||||
<option value="six-flags" {% if request.GET.operator == 'six-flags' %}selected{% endif %}>Six Flags</option>
|
||||
<option value="disney" {% if request.GET.operator == 'disney' %}selected{% endif %}>Disney</option>
|
||||
<option value="universal" {% if request.GET.operator == 'universal' %}selected{% endif %}>Universal</option>
|
||||
<option value="busch-gardens" {% if request.GET.operator == 'busch-gardens' %}selected{% endif %}>Busch Gardens</option>
|
||||
<option value="knott's" {% if request.GET.operator == 'knotts' %}selected{% endif %}>Knott's</option>
|
||||
<option value="herschend" {% if request.GET.operator == 'herschend' %}selected{% endif %}>Herschend</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Rating Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Rating
|
||||
</label>
|
||||
<select name="min_rating"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="">Any Rating</option>
|
||||
<option value="1" {% if request.GET.min_rating == '1' %}selected{% endif %}>1+ Stars</option>
|
||||
<option value="2" {% if request.GET.min_rating == '2' %}selected{% endif %}>2+ Stars</option>
|
||||
<option value="3" {% if request.GET.min_rating == '3' %}selected{% endif %}>3+ Stars</option>
|
||||
<option value="4" {% if request.GET.min_rating == '4' %}selected{% endif %}>4+ Stars</option>
|
||||
<option value="5" {% if request.GET.min_rating == '5' %}selected{% endif %}>5+ Stars</option>
|
||||
<option value="6" {% if request.GET.min_rating == '6' %}selected{% endif %}>6+ Stars</option>
|
||||
<option value="7" {% if request.GET.min_rating == '7' %}selected{% endif %}>7+ Stars</option>
|
||||
<option value="8" {% if request.GET.min_rating == '8' %}selected{% endif %}>8+ Stars</option>
|
||||
<option value="9" {% if request.GET.min_rating == '9' %}selected{% endif %}>9+ Stars</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Ride Count Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Rides
|
||||
</label>
|
||||
<input type="number"
|
||||
name="min_rides"
|
||||
value="{{ request.GET.min_rides|default:'' }}"
|
||||
min="0"
|
||||
placeholder="Any number"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Coaster Count Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Coasters
|
||||
</label>
|
||||
<input type="number"
|
||||
name="min_coasters"
|
||||
value="{{ request.GET.min_coasters|default:'' }}"
|
||||
min="0"
|
||||
placeholder="Any number"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Opening Year Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Opening Year
|
||||
</label>
|
||||
<input type="number"
|
||||
name="opening_year"
|
||||
value="{{ request.GET.opening_year|default:'' }}"
|
||||
min="1800"
|
||||
max="2030"
|
||||
placeholder="Any year"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Size Filter #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Size (acres)
|
||||
</label>
|
||||
<input type="number"
|
||||
name="min_size"
|
||||
value="{{ request.GET.min_size|default:'' }}"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="Any size"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
{# Special Features #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Special Features
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="has_roller_coasters"
|
||||
value="true"
|
||||
{% if request.GET.has_roller_coasters %}checked{% endif %}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Roller Coasters</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="has_water_rides"
|
||||
value="true"
|
||||
{% if request.GET.has_water_rides %}checked{% endif %}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Water Rides</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="family_friendly"
|
||||
value="true"
|
||||
{% if request.GET.family_friendly %}checked{% endif %}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Family Friendly</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sort & View Options #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
|
||||
</svg>
|
||||
Sort By
|
||||
</h3>
|
||||
|
||||
<form hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-trigger="change"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true">
|
||||
|
||||
{# Preserve all current filters #}
|
||||
{% for key, value in request.GET.items %}
|
||||
{% if key != 'sort' and key != 'view_mode' %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<select name="sort"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||
<option value="name" {% if request.GET.sort == 'name' or not request.GET.sort %}selected{% endif %}>Name A-Z</option>
|
||||
<option value="-name" {% if request.GET.sort == '-name' %}selected{% endif %}>Name Z-A</option>
|
||||
<option value="-average_rating" {% if request.GET.sort == '-average_rating' %}selected{% endif %}>Highest Rated</option>
|
||||
<option value="average_rating" {% if request.GET.sort == 'average_rating' %}selected{% endif %}>Lowest Rated</option>
|
||||
<option value="-opening_date" {% if request.GET.sort == '-opening_date' %}selected{% endif %}>Newest</option>
|
||||
<option value="opening_date" {% if request.GET.sort == 'opening_date' %}selected{% endif %}>Oldest</option>
|
||||
<option value="-ride_count" {% if request.GET.sort == '-ride_count' %}selected{% endif %}>Most Rides</option>
|
||||
<option value="-coaster_count" {% if request.GET.sort == '-coaster_count' %}selected{% endif %}>Most Coasters</option>
|
||||
<option value="-size" {% if request.GET.sort == '-size' %}selected{% endif %}>Largest</option>
|
||||
<option value="size" {% if request.GET.sort == 'size' %}selected{% endif %}>Smallest</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Clear Filters Button #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<button type="button"
|
||||
onclick="clearAllFilters()"
|
||||
class="w-full px-4 py-3 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white font-medium rounded-lg transition-all duration-200 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Loading Indicator #}
|
||||
<div id="loading-indicator" class="htmx-indicator fixed top-4 right-4 z-50">
|
||||
<div class="bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" 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>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function clearAllFilters() {
|
||||
// Navigate to the base URL without any query parameters
|
||||
window.location.href = "{% url 'parks:park_list' %}";
|
||||
}
|
||||
|
||||
// Enhanced form handling
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-submit forms on change with debouncing
|
||||
const forms = document.querySelectorAll('#filter-form, form[hx-trigger*="change"]');
|
||||
forms.forEach(form => {
|
||||
let timeout;
|
||||
form.addEventListener('change', function(e) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
htmx.trigger(form, 'submit');
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Apply Filters
|
||||
</button>
|
||||
{% if applied_filters %}
|
||||
<a href="{% url 'search:search' %}"
|
||||
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-xs text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Clear Filters
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
// Handle search input with longer delay
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
htmx.trigger(e.target.closest('form'), 'submit');
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,19 @@
|
||||
{# Enhanced Filters Sidebar with Sticky Positioning #}
|
||||
<aside class="xl:col-span-1 order-2 xl:order-1">
|
||||
<div class="sticky top-4 z-30 space-y-6">
|
||||
{# Mobile Filter Toggle Container #}
|
||||
{# Desktop Filter Content Container #}
|
||||
<div id="desktop-filter-panel" class="hidden xl:block">
|
||||
<div class="xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto xl:scrollbar-thin xl:scrollbar-thumb-gray-300 dark:xl:scrollbar-thumb-gray-600 xl:scrollbar-track-transparent">
|
||||
{% include "core/search/filters.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{# Enhanced Results Section with Better Responsiveness #}
|
||||
<main class="xl:col-span-3 order-1 xl:order-2">
|
||||
<div id="results-container" class="space-y-6">
|
||||
{# Mobile Filter Toggle and Panel - Moved to top for better UX #}
|
||||
<div class="xl:hidden">
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg">
|
||||
<button type="button"
|
||||
@@ -29,24 +41,19 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# Mobile Filter Panel - Adjacent to toggle for proper JavaScript functionality #}
|
||||
<div id="mobile-filter-panel" class="hidden border-t border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="p-4">
|
||||
{% block filter_section %}
|
||||
<!-- Mobile filter content -->
|
||||
{% include "core/search/filters.html" %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter Content Container #}
|
||||
<div id="mobile-filter-panel" class="hidden xl:block">
|
||||
<div class="xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto xl:scrollbar-thin xl:scrollbar-thumb-gray-300 dark:xl:scrollbar-thumb-gray-600 xl:scrollbar-track-transparent">
|
||||
{% block filter_section %}
|
||||
<!-- DEBUG: base filtered_list.html filter_section block - timestamp: 2025-08-21 -->
|
||||
{% include "core/search/components/filter_form.html" %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{# Enhanced Results Section with Better Responsiveness #}
|
||||
<main class="xl:col-span-3 order-1 xl:order-2">
|
||||
<div id="results-container" class="space-y-6">
|
||||
{# Enhanced Header Section #}
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg">
|
||||
<div class="p-4 lg:p-6">
|
||||
@@ -180,4 +187,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,100 +1,117 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Search Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<!-- Filters Sidebar -->
|
||||
<div class="lg:w-1/4">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Filter Parks</h2>
|
||||
{% include "search/filters.html" %}
|
||||
<!-- Top Filter Bar -->
|
||||
{% include "core/search/filters.html" %}
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="lg:w-3/4" id="search-results">
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold">
|
||||
Search Results
|
||||
<span class="text-sm font-normal text-gray-500">({{ results.count }} found)</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
{% for park in results %}
|
||||
<div class="p-6 flex flex-col md:flex-row gap-4">
|
||||
<!-- Park Image -->
|
||||
<div class="md:w-48 h-32 bg-gray-200 rounded-lg 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">
|
||||
No Image
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Park Details -->
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
<!-- 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 %}
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600">
|
||||
{% if park.formatted_location %}
|
||||
<p>{{ park.formatted_location }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="mt-2 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">
|
||||
{{ park.average_rating }} ★
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ 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.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">
|
||||
{{ park.ride_count }} Rides
|
||||
</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">
|
||||
{{ park.coaster_count }} Coasters
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
|
||||
{{ park.description }}
|
||||
</p>
|
||||
{% 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 %}
|
||||
|
||||
{% 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-6 text-center text-gray-500">
|
||||
No parks found matching your criteria.
|
||||
</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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# Include required scripts #}
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user