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:
pacnpal
2025-09-23 20:35:44 -04:00
parent fd42ee1161
commit 41fb41838c
14 changed files with 1716 additions and 44 deletions

View 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>

View 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 %}

View File

@@ -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>