mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-25 16:31:09 -05:00
feat: Implement enhanced park list template with improved layout and accessibility features
- Created a new enhanced park list template with a responsive design. - Added skip navigation links for better accessibility. - Introduced an enhanced header section with park statistics overview. - Developed a sidebar for advanced filters and a search section. - Implemented loading overlay and error handling for HTMX requests. - Enhanced park results display with animations and improved empty states. - Added pagination controls with improved UX for navigating park listings.
This commit is contained in:
438
templates/cotton/advanced_filters.html
Normal file
438
templates/cotton/advanced_filters.html
Normal file
@@ -0,0 +1,438 @@
|
||||
{% comment %}
|
||||
Advanced Filters Component - Django Cotton Version
|
||||
|
||||
Comprehensive filtering panel with collapsible sections, range inputs, and advanced options.
|
||||
Provides extensive filtering capabilities for park listings with intuitive UX.
|
||||
|
||||
Usage Examples:
|
||||
|
||||
<c-advanced_filters
|
||||
filter_counts=filter_counts
|
||||
current_filters=request.GET
|
||||
/>
|
||||
|
||||
<c-advanced_filters
|
||||
filter_counts=filter_counts
|
||||
current_filters=request.GET
|
||||
show_advanced=True
|
||||
class="custom-class"
|
||||
/>
|
||||
|
||||
Parameters:
|
||||
- filter_counts: Dictionary with filter statistics (required)
|
||||
- current_filters: Current filter values from request.GET (required)
|
||||
- show_advanced: Whether to show advanced filters by default (default: False)
|
||||
- class: Additional CSS classes (optional)
|
||||
|
||||
Features:
|
||||
- Collapsible filter sections
|
||||
- Range sliders for numeric filters
|
||||
- Multi-select options
|
||||
- Location-based filtering
|
||||
- Date range pickers
|
||||
- Real-time filter counts
|
||||
- Mobile-optimized interface
|
||||
- HTMX integration for seamless updates
|
||||
{% endcomment %}
|
||||
|
||||
<c-vars
|
||||
filter_counts
|
||||
current_filters
|
||||
show_advanced="false"
|
||||
class=""
|
||||
/>
|
||||
|
||||
<!-- Collapsible Sidebar Filter Panel -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 {{ class }}"
|
||||
x-data="advancedFilters()"
|
||||
x-init="init()">
|
||||
|
||||
<!-- Filter Header with Toggle -->
|
||||
<div class="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="flex items-center gap-3 text-left w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-lg"
|
||||
@click="toggleFilters()"
|
||||
:aria-expanded="filtersOpen">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
x-text="activeFilterCount + ' active'"></span>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': filtersOpen }"
|
||||
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>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="ml-4 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium focus:outline-none focus:underline"
|
||||
@click="clearAllFilters()">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Filter Content -->
|
||||
<div x-show="filtersOpen"
|
||||
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"
|
||||
class="overflow-hidden"
|
||||
style="display: none;">
|
||||
<div class="p-4 sm:p-6 space-y-6">
|
||||
|
||||
<!-- Status Filter Section -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Operating Status
|
||||
</h3>
|
||||
|
||||
<select name="status"
|
||||
class="block 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 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="OPERATING" {% if current_filters.status == 'OPERATING' %}selected{% endif %}>
|
||||
🟢 Operating ({{ filter_counts.status_counts.OPERATING|default:0 }})
|
||||
</option>
|
||||
<option value="CLOSED_TEMP" {% if current_filters.status == 'CLOSED_TEMP' %}selected{% endif %}>
|
||||
🟡 Temporarily Closed ({{ filter_counts.status_counts.CLOSED_TEMP|default:0 }})
|
||||
</option>
|
||||
<option value="CLOSED_PERM" {% if current_filters.status == 'CLOSED_PERM' %}selected{% endif %}>
|
||||
🔴 Permanently Closed ({{ filter_counts.status_counts.CLOSED_PERM|default:0 }})
|
||||
</option>
|
||||
<option value="UNDER_CONSTRUCTION" {% if current_filters.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>
|
||||
🚧 Under Construction ({{ filter_counts.status_counts.UNDER_CONSTRUCTION|default:0 }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Park Type Filter Section -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<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="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>
|
||||
Park Type
|
||||
</h3>
|
||||
|
||||
<select name="park_type"
|
||||
class="block 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 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
<option value="">All Types</option>
|
||||
<option value="disney" {% if current_filters.park_type == 'disney' %}selected{% endif %}>
|
||||
🏰 Disney Parks
|
||||
</option>
|
||||
<option value="universal" {% if current_filters.park_type == 'universal' %}selected{% endif %}>
|
||||
🎬 Universal Parks
|
||||
</option>
|
||||
<option value="six_flags" {% if current_filters.park_type == 'six_flags' %}selected{% endif %}>
|
||||
🎢 Six Flags
|
||||
</option>
|
||||
<option value="cedar_fair" {% if current_filters.park_type == 'cedar_fair' %}selected{% endif %}>
|
||||
🌲 Cedar Fair
|
||||
</option>
|
||||
<option value="independent" {% if current_filters.park_type == 'independent' %}selected{% endif %}>
|
||||
⭐ Independent Parks
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Operator Filter Section -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
Operator
|
||||
</h3>
|
||||
|
||||
<select name="operator"
|
||||
class="block 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 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
<option value="">All Operators</option>
|
||||
{% for operator in filter_counts.top_operators %}
|
||||
<option value="{{ operator.operator__id }}"
|
||||
{% if current_filters.operator == operator.operator__id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ operator.operator__name }} ({{ operator.park_count }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Rating Filter Section -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<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="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>
|
||||
Minimum Rating
|
||||
</h3>
|
||||
|
||||
<select name="min_rating"
|
||||
class="block 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 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='has_coasters'], [name='big_parks_only']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
<option value="">Any Rating</option>
|
||||
<option value="3" {% if current_filters.min_rating == '3' %}selected{% endif %}>
|
||||
⭐⭐⭐ 3+ Stars
|
||||
</option>
|
||||
<option value="4" {% if current_filters.min_rating == '4' %}selected{% endif %}>
|
||||
⭐⭐⭐⭐ 4+ Stars
|
||||
</option>
|
||||
<option value="4.5" {% if current_filters.min_rating == '4.5' %}selected{% endif %}>
|
||||
⭐⭐⭐⭐⭐ 4.5+ Stars
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters Toggle -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<button type="button"
|
||||
class="flex items-center justify-between w-full text-sm font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
:aria-expanded="showAdvanced">
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"/>
|
||||
</svg>
|
||||
Advanced Filters
|
||||
</span>
|
||||
<svg class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': showAdvanced }"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters Content -->
|
||||
<div x-show="showAdvanced"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95"
|
||||
class="space-y-6"
|
||||
style="display: none;">
|
||||
|
||||
<!-- Ride Count Range -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Number of Rides</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Minimum</label>
|
||||
<input type="number"
|
||||
name="min_rides"
|
||||
value="{{ current_filters.min_rides }}"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
class="block 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:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-trigger="change delay:500ms"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='max_rides']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Maximum</label>
|
||||
<input type="number"
|
||||
name="max_rides"
|
||||
value="{{ current_filters.max_rides }}"
|
||||
placeholder="∞"
|
||||
min="0"
|
||||
class="block 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:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-trigger="change delay:500ms"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='min_rides']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coaster Count Range -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Number of Roller Coasters</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Minimum</label>
|
||||
<input type="number"
|
||||
name="min_coasters"
|
||||
value="{{ current_filters.min_coasters }}"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
class="block 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:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-trigger="change delay:500ms"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='max_coasters']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Maximum</label>
|
||||
<input type="number"
|
||||
name="max_coasters"
|
||||
value="{{ current_filters.max_coasters }}"
|
||||
placeholder="∞"
|
||||
min="0"
|
||||
class="block 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:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-trigger="change delay:500ms"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='min_coasters']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Filters -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Location</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Country</label>
|
||||
<input type="text"
|
||||
name="country_filter"
|
||||
value="{{ current_filters.country_filter }}"
|
||||
placeholder="e.g., United States"
|
||||
class="block 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:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='state_filter']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">State/Region</label>
|
||||
<input type="text"
|
||||
name="state_filter"
|
||||
value="{{ current_filters.state_filter }}"
|
||||
placeholder="e.g., California"
|
||||
class="block 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:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='country_filter']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opening Date Range -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Opening Date</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From</label>
|
||||
<input type="date"
|
||||
name="opening_date_after"
|
||||
value="{{ current_filters.opening_date_after }}"
|
||||
class="block 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:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-trigger="change delay:500ms"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='opening_date_before']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To</label>
|
||||
<input type="date"
|
||||
name="opening_date_before"
|
||||
value="{{ current_filters.opening_date_before }}"
|
||||
class="block 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:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-trigger="change delay:500ms"
|
||||
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='opening_date_after']"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-spinner">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AlpineJS Component Script -->
|
||||
<script>
|
||||
function advancedFilters() {
|
||||
return {
|
||||
showAdvanced: {{ show_advanced|yesno:"true,false" }},
|
||||
filtersOpen: window.innerWidth >= 1024, // Open by default on desktop, closed on mobile
|
||||
activeFilterCount: 0,
|
||||
|
||||
init() {
|
||||
this.updateActiveFilterCount();
|
||||
// Handle responsive behavior
|
||||
this.handleResize();
|
||||
window.addEventListener('resize', () => this.handleResize());
|
||||
},
|
||||
|
||||
handleResize() {
|
||||
// Auto-open filters on desktop, keep user preference on mobile
|
||||
if (window.innerWidth >= 1024) {
|
||||
this.filtersOpen = true;
|
||||
}
|
||||
},
|
||||
|
||||
toggleFilters() {
|
||||
this.filtersOpen = !this.filtersOpen;
|
||||
},
|
||||
|
||||
updateActiveFilterCount() {
|
||||
// Count active filters from current_filters
|
||||
let count = 0;
|
||||
try {
|
||||
// Convert Django QueryDict to JavaScript object safely
|
||||
const filters = {
|
||||
{% for key, value in current_filters.items %}
|
||||
{% if key != 'page' and key != 'view_mode' and value %}
|
||||
"{{ key|escapejs }}": "{{ value|escapejs }}",
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
count = Object.keys(filters).length;
|
||||
} catch (error) {
|
||||
console.warn('Error counting active filters:', error);
|
||||
count = 0;
|
||||
}
|
||||
|
||||
this.activeFilterCount = count;
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
// Navigate to clean URL
|
||||
window.location.href = '{% url "parks:park_list" %}';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
381
templates/cotton/enhanced_park_card.html
Normal file
381
templates/cotton/enhanced_park_card.html
Normal file
@@ -0,0 +1,381 @@
|
||||
{% comment %}
|
||||
Enhanced Park Card Component - Django Cotton Version
|
||||
|
||||
A modern, responsive park card component with improved layouts for both grid and list views.
|
||||
Features enhanced visual design, better mobile optimization, and rich interactive elements.
|
||||
|
||||
Usage Examples:
|
||||
|
||||
Grid View:
|
||||
<c-enhanced_park_card
|
||||
park=park
|
||||
view_mode="grid"
|
||||
/>
|
||||
|
||||
List View:
|
||||
<c-enhanced_park_card
|
||||
park=park
|
||||
view_mode="list"
|
||||
/>
|
||||
|
||||
Compact Grid View:
|
||||
<c-enhanced_park_card
|
||||
park=park
|
||||
view_mode="grid"
|
||||
size="compact"
|
||||
/>
|
||||
|
||||
Parameters:
|
||||
- park: Park object (required)
|
||||
- view_mode: "list" or "grid" (default: "grid")
|
||||
- size: "normal" or "compact" (default: "normal")
|
||||
- show_stats: Whether to show ride/coaster stats (default: True)
|
||||
- show_rating: Whether to show rating (default: True)
|
||||
- class: Additional CSS classes (optional)
|
||||
|
||||
Features:
|
||||
- Modern card design with enhanced visual hierarchy
|
||||
- Responsive image handling with CloudFlare Images
|
||||
- Interactive hover effects and animations
|
||||
- Accessibility improvements with ARIA labels
|
||||
- Status badges with improved styling
|
||||
- Rating display with star visualization
|
||||
- Optimized for both mobile and desktop
|
||||
- Support for compact grid layout
|
||||
- Enhanced typography and spacing
|
||||
{% endcomment %}
|
||||
|
||||
<c-vars
|
||||
park
|
||||
view_mode="grid"
|
||||
size="normal"
|
||||
show_stats="true"
|
||||
show_rating="true"
|
||||
class=""
|
||||
/>
|
||||
|
||||
{% if park %}
|
||||
{% if view_mode == 'list' %}
|
||||
{# Enhanced List View Layout #}
|
||||
<article class="group bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm border border-gray-200/60 dark:border-gray-700/60 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 transform hover:scale-[1.01] overflow-hidden {{ class }}"
|
||||
role="article"
|
||||
aria-labelledby="park-title-{{ park.id }}"
|
||||
aria-describedby="park-description-{{ park.id }}">
|
||||
|
||||
<div class="p-5 sm:p-7">
|
||||
<div class="flex flex-col lg:flex-row gap-5 lg:gap-7">
|
||||
{# Enhanced Image Section for List View #}
|
||||
<div class="flex-shrink-0 w-full lg:w-64 xl:w-72">
|
||||
<div class="relative aspect-[16/9] lg:aspect-[4/3] bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-xl overflow-hidden group-hover:shadow-lg transition-shadow duration-300">
|
||||
{% if park.card_image.image or park.photos.first.image %}
|
||||
{% with image=park.card_image.image|default:park.photos.first.image %}
|
||||
<picture class="w-full h-full">
|
||||
<source media="(max-width: 1023px)"
|
||||
srcset="{{ image.public_url }}/w=800,h=450,fit=cover,f=webp 1x, {{ image.public_url }}/w=1600,h=900,fit=cover,f=webp 2x"
|
||||
type="image/webp">
|
||||
<source media="(min-width: 1024px)"
|
||||
srcset="{{ image.public_url }}/w=600,h=450,fit=cover,f=webp 1x, {{ image.public_url }}/w=1200,h=900,fit=cover,f=webp 2x"
|
||||
type="image/webp">
|
||||
<img src="{{ image.public_url }}/w=800,h=450,fit=cover"
|
||||
alt="{{ park.name }} - {% if park.card_image.alt_text %}{{ park.card_image.alt_text }}{% elif park.photos.first.alt_text %}{{ park.photos.first.alt_text }}{% else %}Theme park exterior view{% endif %}"
|
||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
decoding="async">
|
||||
</picture>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="flex items-center justify-center h-full text-white/70 bg-gradient-to-br from-gray-400 via-gray-500 to-gray-600 dark:from-gray-600 dark:via-gray-700 dark:to-gray-800">
|
||||
<div class="text-center p-6">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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"></path>
|
||||
</svg>
|
||||
<p class="text-sm font-medium opacity-80">No Image Available</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Enhanced Status Badge #}
|
||||
<div class="absolute top-3 right-3">
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-xs font-bold border-2 bg-white/95 backdrop-blur-sm shadow-lg
|
||||
{% if park.status == 'operating' or park.status == 'OPERATING' %}text-green-700 border-green-300 shadow-green-200/50
|
||||
{% elif park.status == 'closed' or park.status == 'CLOSED_PERM' %}text-red-700 border-red-300 shadow-red-200/50
|
||||
{% elif park.status == 'closed_temp' or park.status == 'CLOSED_TEMP' %}text-yellow-700 border-yellow-300 shadow-yellow-200/50
|
||||
{% elif park.status == 'under_construction' or park.status == 'UNDER_CONSTRUCTION' %}text-blue-700 border-blue-300 shadow-blue-200/50
|
||||
{% else %}text-gray-700 border-gray-300 shadow-gray-200/50{% endif %}"
|
||||
role="img"
|
||||
aria-label="Park status: {{ park.get_status_display }}"
|
||||
title="Park status: {{ park.get_status_display }}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Rating Badge (if enabled) #}
|
||||
{% if show_rating == "true" and park.average_rating %}
|
||||
<div class="absolute bottom-3 left-3">
|
||||
<div class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-black/70 text-white backdrop-blur-sm">
|
||||
<svg class="w-3 h-3 mr-1 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.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>
|
||||
{{ park.average_rating|floatformat:1 }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Content Section #}
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div class="space-y-4">
|
||||
{# Title and Operator #}
|
||||
<div>
|
||||
<h3 id="park-title-{{ park.id }}" class="text-xl sm:text-2xl lg:text-3xl font-bold line-clamp-2 leading-tight mb-2">
|
||||
{% if park.slug %}
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent hover:from-blue-600 hover:to-purple-600 dark:hover:from-blue-400 dark:hover:to-purple-400 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-sm"
|
||||
aria-label="View details for {{ park.name }}">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent">
|
||||
{{ park.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
||||
{% if park.operator %}
|
||||
<div class="flex items-center text-base font-medium text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 mr-2 flex-shrink-0 opacity-60" 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>
|
||||
<span class="truncate">{{ park.operator.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Description #}
|
||||
{% if park.description %}
|
||||
<p id="park-description-{{ park.id }}" class="text-gray-600 dark:text-gray-400 line-clamp-3 leading-relaxed text-base">
|
||||
{{ park.description|truncatewords:40 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# Location Info #}
|
||||
{% if park.location %}
|
||||
<div class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 mr-2 flex-shrink-0" 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>
|
||||
<span>
|
||||
{% if park.location.city %}{{ park.location.city }}{% endif %}{% if park.location.city and park.location.state %}, {% endif %}{% if park.location.state %}{{ park.location.state }}{% endif %}{% if park.location.country and park.location.country != "United States" %}, {{ park.location.country }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Stats Section #}
|
||||
{% if show_stats == "true" and park.ride_count or park.coaster_count %}
|
||||
<div class="flex items-center justify-between pt-5 border-t border-gray-200/60 dark:border-gray-600/60 mt-5">
|
||||
<div class="flex items-center space-x-6">
|
||||
{% if park.ride_count %}
|
||||
<div class="flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-xl border border-blue-200/50 dark:border-blue-800/50"
|
||||
role="img"
|
||||
aria-label="{{ park.ride_count }} ride{{ park.ride_count|pluralize }} available"
|
||||
title="{{ park.ride_count }} ride{{ park.ride_count|pluralize }}">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
<span class="font-bold text-blue-700 dark:text-blue-300 text-lg">{{ park.ride_count }}</span>
|
||||
<span class="text-blue-600 dark:text-blue-400 font-medium">rides</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.coaster_count %}
|
||||
<div class="flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-xl border border-purple-200/50 dark:border-purple-800/50"
|
||||
role="img"
|
||||
aria-label="{{ park.coaster_count }} roller coaster{{ park.coaster_count|pluralize }} available"
|
||||
title="{{ park.coaster_count }} roller coaster{{ park.coaster_count|pluralize }}">
|
||||
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-bold text-purple-700 dark:text-purple-300 text-lg">{{ park.coaster_count }}</span>
|
||||
<span class="text-purple-600 dark:text-purple-400 font-medium">coasters</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# View Details Arrow #}
|
||||
<div class="text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-300">
|
||||
<svg class="w-6 h-6 transform group-hover:translate-x-2 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
{# Enhanced Grid View Layout #}
|
||||
<article class="group bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm border border-gray-200/60 dark:border-gray-700/60 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 transform hover:scale-105 hover:-rotate-1 overflow-hidden {{ class }}"
|
||||
role="article"
|
||||
aria-labelledby="park-title-grid-{{ park.id }}"
|
||||
aria-describedby="park-description-grid-{{ park.id }}">
|
||||
|
||||
{# Enhanced Image Section for Grid View #}
|
||||
<div class="relative {% if size == 'compact' %}aspect-[4/3]{% else %}aspect-[4/3]{% endif %} bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 overflow-hidden">
|
||||
{% if park.card_image.image or park.photos.first.image %}
|
||||
{% with image=park.card_image.image|default:park.photos.first.image %}
|
||||
<picture class="w-full h-full">
|
||||
{% if size == "compact" %}
|
||||
<source media="(max-width: 767px)"
|
||||
srcset="{{ image.public_url }}/w=400,h=300,fit=cover,f=webp 1x, {{ image.public_url }}/w=800,h=600,fit=cover,f=webp 2x"
|
||||
type="image/webp">
|
||||
<source media="(min-width: 768px)"
|
||||
srcset="{{ image.public_url }}/w=300,h=225,fit=cover,f=webp 1x, {{ image.public_url }}/w=600,h=450,fit=cover,f=webp 2x"
|
||||
type="image/webp">
|
||||
{% else %}
|
||||
<source media="(max-width: 767px)"
|
||||
srcset="{{ image.public_url }}/w=600,h=450,fit=cover,f=webp 1x, {{ image.public_url }}/w=1200,h=900,fit=cover,f=webp 2x"
|
||||
type="image/webp">
|
||||
<source media="(min-width: 768px)"
|
||||
srcset="{{ image.public_url }}/w=400,h=300,fit=cover,f=webp 1x, {{ image.public_url }}/w=800,h=600,fit=cover,f=webp 2x"
|
||||
type="image/webp">
|
||||
{% endif %}
|
||||
<img src="{{ image.public_url }}/w=600,h=450,fit=cover"
|
||||
alt="{{ park.name }} - {% if park.card_image.alt_text %}{{ park.card_image.alt_text }}{% elif park.photos.first.alt_text %}{{ park.photos.first.alt_text }}{% else %}Theme park exterior view{% endif %}"
|
||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
decoding="async">
|
||||
</picture>
|
||||
|
||||
{# Image Overlay Effects #}
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/30 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="flex flex-col items-center justify-center h-full text-white/70 bg-gradient-to-br from-gray-400 via-gray-500 to-gray-600 dark:from-gray-600 dark:via-gray-700 dark:to-gray-800">
|
||||
<div class="p-6 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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"></path>
|
||||
</svg>
|
||||
<p class="text-sm font-medium opacity-80">No Image Available</p>
|
||||
<p class="text-xs opacity-60 mt-1">{{ park.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Enhanced Status Badge #}
|
||||
<div class="absolute top-3 right-3">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold border-2 bg-white/95 backdrop-blur-sm shadow-lg
|
||||
{% if park.status == 'operating' or park.status == 'OPERATING' %}text-green-700 border-green-300 shadow-green-200/50
|
||||
{% elif park.status == 'closed' or park.status == 'CLOSED_PERM' %}text-red-700 border-red-300 shadow-red-200/50
|
||||
{% elif park.status == 'closed_temp' or park.status == 'CLOSED_TEMP' %}text-yellow-700 border-yellow-300 shadow-yellow-200/50
|
||||
{% elif park.status == 'under_construction' or park.status == 'UNDER_CONSTRUCTION' %}text-blue-700 border-blue-300 shadow-blue-200/50
|
||||
{% else %}text-gray-700 border-gray-300 shadow-gray-200/50{% endif %}"
|
||||
role="img"
|
||||
aria-label="Park status: {{ park.get_status_display }}"
|
||||
title="Park status: {{ park.get_status_display }}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Rating Badge (if enabled) #}
|
||||
{% if show_rating == "true" and park.average_rating %}
|
||||
<div class="absolute bottom-3 left-3">
|
||||
<div class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-black/70 text-white backdrop-blur-sm">
|
||||
<svg class="w-3 h-3 mr-1 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.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>
|
||||
{{ park.average_rating|floatformat:1 }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Enhanced Content Area #}
|
||||
<div class="p-5 {% if size == 'compact' %}sm:p-4{% else %}sm:p-6{% endif %}">
|
||||
<div class="{% if size == 'compact' %}mb-3{% else %}mb-4{% endif %}">
|
||||
{# Title #}
|
||||
<h3 id="park-title-grid-{{ park.id }}" class="{% if size == 'compact' %}text-lg{% else %}text-xl{% endif %} font-bold line-clamp-2 mb-2 leading-tight">
|
||||
{% if park.slug %}
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-sm"
|
||||
aria-label="View details for {{ park.name }}">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-900 dark:text-white">
|
||||
{{ park.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
||||
{# Operator #}
|
||||
{% if park.operator %}
|
||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3 truncate flex items-center">
|
||||
<svg class="w-3 h-3 mr-1.5 flex-shrink-0 opacity-60" 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>
|
||||
<span class="truncate">{{ park.operator.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Description #}
|
||||
{% if park.description and size != "compact" %}
|
||||
<p id="park-description-grid-{{ park.id }}" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-3 mb-4 leading-relaxed">
|
||||
{{ park.description|truncatewords:20 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# Stats Footer #}
|
||||
{% if show_stats == "true" and park.ride_count or park.coaster_count %}
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200/60 dark:border-gray-600/60">
|
||||
<div class="flex items-center space-x-4 text-sm">
|
||||
{% if park.ride_count %}
|
||||
<div class="flex items-center space-x-1.5 text-blue-600 dark:text-blue-400"
|
||||
role="img"
|
||||
aria-label="{{ park.ride_count }} ride{{ park.ride_count|pluralize }} available"
|
||||
title="{{ park.ride_count }} ride{{ park.ride_count|pluralize }}">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
<span class="font-bold">{{ park.ride_count }}</span>
|
||||
{% if size != "compact" %}<span class="text-xs opacity-75">rides</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.coaster_count %}
|
||||
<div class="flex items-center space-x-1.5 text-purple-600 dark:text-purple-400"
|
||||
role="img"
|
||||
aria-label="{{ park.coaster_count }} roller coaster{{ park.coaster_count|pluralize }} available"
|
||||
title="{{ park.coaster_count }} roller coaster{{ park.coaster_count|pluralize }}">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-bold">{{ park.coaster_count }}</span>
|
||||
{% if size != "compact" %}<span class="text-xs opacity-75">coasters</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# View Details Arrow #}
|
||||
<div class="text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-300">
|
||||
<svg class="w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Show arrow even when no stats for consistent layout #}
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200/60 dark:border-gray-600/60">
|
||||
<div class="text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-300">
|
||||
<svg class="w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -54,13 +54,19 @@ Features:
|
||||
this.open = false;
|
||||
this.suggestions = [];
|
||||
this.selectedIndex = -1;
|
||||
htmx.trigger(this.$refs.searchInput, 'keyup');
|
||||
// Trigger search update if HTMX is available
|
||||
if (typeof htmx !== 'undefined' && this.$refs.searchInput) {
|
||||
htmx.trigger(this.$refs.searchInput, 'keyup');
|
||||
}
|
||||
},
|
||||
selectSuggestion(suggestion) {
|
||||
this.search = suggestion.name || suggestion;
|
||||
this.open = false;
|
||||
this.selectedIndex = -1;
|
||||
htmx.trigger(this.$refs.searchInput, 'keyup');
|
||||
// Trigger search update if HTMX is available
|
||||
if (typeof htmx !== 'undefined' && this.$refs.searchInput) {
|
||||
htmx.trigger(this.$refs.searchInput, 'keyup');
|
||||
}
|
||||
},
|
||||
handleKeydown(event) {
|
||||
if (!this.open) return;
|
||||
@@ -260,4 +266,4 @@ Features:
|
||||
`${suggestions.length} suggestion${suggestions.length !== 1 ? 's' : ''} available. Use arrow keys to navigate.` :
|
||||
(search.length >= 2 && !loading && suggestions.length === 0 ? 'No suggestions found.' : '')">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user